diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index c129f801a1..02629ea169 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -34,7 +34,7 @@ src/components/views/rooms/LinkPreviewWidget.js src/components/views/rooms/MemberDeviceInfo.js src/components/views/rooms/MemberInfo.js src/components/views/rooms/MemberList.js -src/components/views/rooms/MessageComposer.js +src/components/views/rooms/SlateMessageComposer.js src/components/views/rooms/PinnedEventTile.js src/components/views/rooms/RoomList.js src/components/views/rooms/RoomPreviewBar.js diff --git a/src/SlateComposerHistoryManager.js b/src/SlateComposerHistoryManager.js new file mode 100644 index 0000000000..948dcf64ff --- /dev/null +++ b/src/SlateComposerHistoryManager.js @@ -0,0 +1,86 @@ +//@flow +/* +Copyright 2017 Aviral Dasgupta + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Value} from 'slate'; + +import _clamp from 'lodash/clamp'; + +type MessageFormat = 'rich' | 'markdown'; + +class HistoryItem { + // We store history items in their native format to ensure history is accurate + // and then convert them if our RTE has subsequently changed format. + value: Value; + format: MessageFormat = 'rich'; + + constructor(value: ?Value, format: ?MessageFormat) { + this.value = value; + this.format = format; + } + + static fromJSON(obj: Object): HistoryItem { + return new HistoryItem( + Value.fromJSON(obj.value), + obj.format, + ); + } + + toJSON(): Object { + return { + value: this.value.toJSON(), + format: this.format, + }; + } +} + +export default class SlateComposerHistoryManager { + history: Array = []; + prefix: string; + lastIndex: number = 0; // used for indexing the storage + currentIndex: number = 0; // used for indexing the loaded validated history Array + + constructor(roomId: string, prefix: string = 'mx_composer_history_') { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + let item; + for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { + try { + this.history.push( + HistoryItem.fromJSON(JSON.parse(item)), + ); + } catch (e) { + console.warn("Throwing away unserialisable history", e); + } + } + this.lastIndex = this.currentIndex; + // reset currentIndex to account for any unserialisable history + this.currentIndex = this.history.length; + } + + save(value: Value, format: MessageFormat) { + const item = new HistoryItem(value, format); + this.history.push(item); + this.currentIndex = this.history.length; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); + } + + getItem(offset: number): ?HistoryItem { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + return this.history[this.currentIndex]; + } +} diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index ca25ada12d..df7ba27493 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -61,7 +61,7 @@ import ReplyThread from "../elements/ReplyThread"; import {ContentHelpers} from 'matrix-js-sdk'; import AccessibleButton from '../elements/AccessibleButton'; import {findEditableEvent} from '../../../utils/EventUtils'; -import ComposerHistoryManager from "../../../ComposerHistoryManager"; +import SlateComposerHistoryManager from "../../../SlateComposerHistoryManager"; import TypingStore from "../../../stores/TypingStore"; const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -141,7 +141,7 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; - historyManager: ComposerHistoryManager; + historyManager: SlateComposerHistoryManager; constructor(props, context) { super(props, context); @@ -331,7 +331,7 @@ export default class MessageComposerInput extends React.Component { componentWillMount() { this.dispatcherRef = dis.register(this.onAction); - this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); + this.historyManager = new SlateComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); } componentWillUnmount() { diff --git a/src/components/views/rooms/SlateMessageComposer.js b/src/components/views/rooms/SlateMessageComposer.js new file mode 100644 index 0000000000..d7aa745753 --- /dev/null +++ b/src/components/views/rooms/SlateMessageComposer.js @@ -0,0 +1,489 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017, 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import PropTypes from 'prop-types'; +import { _t, _td } from '../../../languageHandler'; +import CallHandler from '../../../CallHandler'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import RoomViewStore from '../../../stores/RoomViewStore'; +import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; +import Stickerpicker from './Stickerpicker'; +import { makeRoomPermalink } from '../../../matrix-to'; +import ContentMessages from '../../../ContentMessages'; +import classNames from 'classnames'; + +import E2EIcon from './E2EIcon'; + +const formatButtonList = [ + _td("bold"), + _td("italic"), + _td("deleted"), + _td("underlined"), + _td("inline-code"), + _td("block-quote"), + _td("bulleted-list"), + _td("numbered-list"), +]; + +function ComposerAvatar(props) { + const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); + return
+ +
; +} + +ComposerAvatar.propTypes = { + me: PropTypes.object.isRequired, +} + +function CallButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const onVoiceCallClick = (ev) => { + dis.dispatch({ + action: 'place_call', + type: "voice", + room_id: props.roomId, + }); + }; + + return +} + +CallButton.propTypes = { + roomId: PropTypes.string.isRequired +} + +function VideoCallButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const onCallClick = (ev) => { + dis.dispatch({ + action: 'place_call', + type: ev.shiftKey ? "screensharing" : "video", + room_id: props.roomId, + }); + }; + + return ; +} + +VideoCallButton.propTypes = { + roomId: PropTypes.string.isRequired, +}; + +function HangupButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const onHangupClick = () => { + const call = CallHandler.getCallForRoom(props.roomId); + if (!call) { + return; + } + dis.dispatch({ + action: 'hangup', + // hangup the call for this room, which may not be the room in props + // (e.g. conferences which will hangup the 1:1 room instead) + room_id: call.roomId, + }); + }; + return ; +} + +HangupButton.propTypes = { + roomId: PropTypes.string.isRequired, +} + +function FormattingButton(props) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ; +} + +FormattingButton.propTypes = { + showFormatting: PropTypes.bool.isRequired, + onClickHandler: PropTypes.func.isRequired, +} + +class UploadButton extends React.Component { + static propTypes = { + roomId: PropTypes.string.isRequired, + } + constructor(props, context) { + super(props, context); + this.onUploadClick = this.onUploadClick.bind(this); + this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this); + } + + onUploadClick(ev) { + if (MatrixClientPeg.get().isGuest()) { + dis.dispatch({action: 'require_registration'}); + return; + } + this.refs.uploadInput.click(); + } + + onUploadFileInputChange(ev) { + if (ev.target.files.length === 0) return; + + // take a copy so we can safely reset the value of the form control + // (Note it is a FileList: we can't use slice or sesnible iteration). + const tfiles = []; + for (let i = 0; i < ev.target.files.length; ++i) { + tfiles.push(ev.target.files[i]); + } + + ContentMessages.sharedInstance().sendContentListToRoom( + tfiles, this.props.roomId, MatrixClientPeg.get(), + ); + + // This is the onChange handler for a file form control, but we're + // not keeping any state, so reset the value of the form control + // to empty. + // NB. we need to set 'value': the 'files' property is immutable. + ev.target.value = ''; + } + + render() { + const uploadInputStyle = {display: 'none'}; + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + + + ); + } +} + +export default class SlateMessageComposer extends React.Component { + constructor(props, context) { + super(props, context); + this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); + this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this); + this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this); + this.onInputStateChanged = this.onInputStateChanged.bind(this); + this.onEvent = this.onEvent.bind(this); + this._onRoomStateEvents = this._onRoomStateEvents.bind(this); + this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); + this._onTombstoneClick = this._onTombstoneClick.bind(this); + this.renderPlaceholderText = this.renderPlaceholderText.bind(this); + this.renderFormatBar = this.renderFormatBar.bind(this); + + this.state = { + inputState: { + marks: [], + blockType: null, + isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'), + }, + showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'), + isQuoting: Boolean(RoomViewStore.getQuotingEvent()), + tombstone: this._getRoomTombstone(), + canSendMessages: this.props.room.maySendMessage(), + }; + } + + componentDidMount() { + // N.B. using 'event' rather than 'RoomEvents' otherwise the crypto handler + // for 'event' fires *after* 'RoomEvent', and our room won't have yet been + // marked as encrypted. + // XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something. + MatrixClientPeg.get().on("event", this.onEvent); + MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); + this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); + this._waitForOwnMember(); + } + + _waitForOwnMember() { + // if we have the member already, do that + const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()); + if (me) { + this.setState({me}); + return; + } + // Otherwise, wait for member loading to finish and then update the member for the avatar. + // The members should already be loading, and loadMembersIfNeeded + // will return the promise for the existing operation + this.props.room.loadMembersIfNeeded().then(() => { + const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()); + this.setState({me}); + }); + } + + componentWillUnmount() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("event", this.onEvent); + MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); + } + if (this._roomStoreToken) { + this._roomStoreToken.remove(); + } + } + + onEvent(event) { + if (event.getType() !== 'm.room.encryption') return; + if (event.getRoomId() !== this.props.room.roomId) return; + this.forceUpdate(); + } + + _onRoomStateEvents(ev, state) { + if (ev.getRoomId() !== this.props.room.roomId) return; + + if (ev.getType() === 'm.room.tombstone') { + this.setState({tombstone: this._getRoomTombstone()}); + } + if (ev.getType() === 'm.room.power_levels') { + this.setState({canSendMessages: this.props.room.maySendMessage()}); + } + } + + _getRoomTombstone() { + return this.props.room.currentState.getStateEvents('m.room.tombstone', ''); + } + + _onRoomViewStoreUpdate() { + const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); + if (this.state.isQuoting === isQuoting) return; + this.setState({ isQuoting }); + } + + + onInputStateChanged(inputState) { + // Merge the new input state with old to support partial updates + inputState = Object.assign({}, this.state.inputState, inputState); + this.setState({inputState}); + } + + _onAutocompleteConfirm(range, completion) { + if (this.messageComposerInput) { + this.messageComposerInput.setDisplayedCompletion(range, completion); + } + } + + onFormatButtonClicked(name, event) { + event.preventDefault(); + this.messageComposerInput.onFormatButtonClicked(name, event); + } + + onToggleFormattingClicked() { + SettingsStore.setValue("MessageComposer.showFormatting", null, SettingLevel.DEVICE, !this.state.showFormatting); + this.setState({showFormatting: !this.state.showFormatting}); + } + + onToggleMarkdownClicked(e) { + e.preventDefault(); // don't steal focus from the editor! + this.messageComposerInput.enableRichtext(!this.state.inputState.isRichTextEnabled); + } + + _onTombstoneClick(ev) { + ev.preventDefault(); + + const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; + const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId); + let createEventId = null; + if (replacementRoom) { + const createEvent = replacementRoom.currentState.getStateEvents('m.room.create', ''); + if (createEvent && createEvent.getId()) createEventId = createEvent.getId(); + } + + const viaServers = [this.state.tombstone.getSender().split(':').splice(1).join(':')]; + dis.dispatch({ + action: 'view_room', + highlighted: true, + event_id: createEventId, + room_id: replacementRoomId, + auto_join: true, + + // Try to join via the server that sent the event. This converts @something:example.org + // into a server domain by splitting on colons and ignoring the first entry ("@something"). + via_servers: viaServers, + opts: { + // These are passed down to the js-sdk's /join call + viaServers: viaServers, + }, + }); + } + + renderPlaceholderText() { + const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); + if (this.state.isQuoting) { + if (roomIsEncrypted) { + return _t('Send an encrypted reply…'); + } else { + return _t('Send a reply (unencrypted)…'); + } + } else { + if (roomIsEncrypted) { + return _t('Send an encrypted message…'); + } else { + return _t('Send a message (unencrypted)…'); + } + } + } + + renderFormatBar() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const {marks, blockType} = this.state.inputState; + const formatButtons = formatButtonList.map((name) => { + // special-case to match the md serializer and the special-case in MessageComposerInput.js + const markName = name === 'inline-code' ? 'code' : name; + const active = marks.some(mark => mark.type === markName) || blockType === name; + const suffix = active ? '-on' : ''; + const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); + const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; + return ( + + ); + }) + + return ( +
+
+ { formatButtons } +
+ + +
+
+ ); + } + + render() { + const controls = [ + this.state.me ? : null, + this.props.e2eStatus ? : null, + ]; + + if (!this.state.tombstone && this.state.canSendMessages) { + // This also currently includes the call buttons. Really we should + // check separately for whether we can call, but this is slightly + // complex because of conference calls. + + const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput"); + const showFormattingButton = this.state.inputState.isRichTextEnabled; + const callInProgress = this.props.callState && this.props.callState !== 'ended'; + + controls.push( + this.messageComposerInput = c} + key="controls_input" + room={this.props.room} + placeholder={this.renderPlaceholderText()} + onInputStateChanged={this.onInputStateChanged} + permalinkCreator={this.props.permalinkCreator} />, + showFormattingButton ? : null, + , + , + callInProgress ? : null, + callInProgress ? null : , + callInProgress ? null : , + ); + } else if (this.state.tombstone) { + const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; + + const continuesLink = replacementRoomId ? ( + + {_t("The conversation continues here.")} + + ) : ''; + + controls.push(
+
+ + + {_t("This room has been replaced and is no longer active.")} +
+ { continuesLink } +
+
); + } else { + controls.push( +
+ { _t('You do not have permission to post to this room') } +
, + ); + } + + const showFormatBar = this.state.showFormatting && this.state.inputState.isRichTextEnabled; + + const wrapperClasses = classNames({ + mx_MessageComposer_wrapper: true, + mx_MessageComposer_hasE2EIcon: !!this.props.e2eStatus, + }); + return ( +
+
+
+ { controls } +
+
+ { showFormatBar ? this.renderFormatBar() : null } +
+ ); + } +} + +SlateMessageComposer.propTypes = { + // js-sdk Room object + room: PropTypes.object.isRequired, + + // string representing the current voip call state + callState: PropTypes.string, + + // string representing the current room app drawer state + showApps: PropTypes.bool +};