diff --git a/package.json b/package.json index 3ae985d49d..c639895973 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ "dependencies": { "browser-request": "^0.3.3", "classnames": "^2.1.2", + "draft-js": "^0.7.0", + "draft-js-export-html": "^0.2.2", + "draft-js-export-markdown": "^0.2.0", + "draft-js-import-markdown": "^0.1.6", "favico.js": "^0.3.10", "filesize": "^3.1.2", "flux": "^2.0.3", @@ -31,15 +35,14 @@ "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", "marked": "^0.3.5", - "matrix-js-sdk": "^0.5.4", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.0.1", "react-dom": "^15.0.1", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "sanitize-html": "^1.11.1", - "velocity-animate": "^1.2.3", - "velocity-ui-pack": "^1.2.2" + "velocity-vector": "vector-im/velocity#059e3b2" }, "//babelversion": [ "brief experiments with babel6 seems to show that it generates source ", diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index cc96503316..143b804228 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -50,6 +50,11 @@ function createClientForPeg(hs_url, is_url, user_id, access_token, guestAccess) } matrixClient = Matrix.createClient(opts); + + // we're going to add eventlisteners for each matrix event tile, so the + // potential number of event listeners is quite high. + matrixClient.setMaxListeners(500); + if (guestAccess) { console.log("Guest: %s", guestAccess.isGuest()); matrixClient.setGuest(guestAccess.isGuest()); @@ -91,7 +96,7 @@ class MatrixClient { } // FIXME, XXX: this all seems very convoluted :( - // + // // if we replace the singleton using URLs we bypass our createClientForPeg() // global helper function... but if we replace it using // an access_token we don't? diff --git a/src/MatrixTools.js b/src/MatrixTools.js index 7fded4adea..372f17f69c 100644 --- a/src/MatrixTools.js +++ b/src/MatrixTools.js @@ -16,21 +16,13 @@ limitations under the License. module.exports = { /** - * Given a room object, return the canonical alias for it - * if there is one. Otherwise return null; + * Given a room object, return the alias we should use for it, + * if any. This could be the canonical alias if one exists, otherwise + * an alias selected arbitrarily but deterministically from the list + * of aliases. Otherwise return null; */ - getCanonicalAliasForRoom: function(room) { - var aliasEvents = room.currentState.getStateEvents( - "m.room.aliases" - ); - // Canonical aliases aren't implemented yet, so just return the first - for (var j = 0; j < aliasEvents.length; j++) { - var aliases = aliasEvents[j].getContent().aliases; - if (aliases && aliases.length) { - return aliases[0]; - } - } - return null; + getDisplayAliasForRoom: function(room) { + return room.getCanonicalAlias() || room.getAliases()[0]; }, /** diff --git a/src/RichText.js b/src/RichText.js new file mode 100644 index 0000000000..7e749bc24a --- /dev/null +++ b/src/RichText.js @@ -0,0 +1,170 @@ +import { + Editor, + Modifier, + ContentState, + convertFromHTML, + DefaultDraftBlockRenderMap, + DefaultDraftInlineStyle, + CompositeDecorator +} from 'draft-js'; +import * as sdk from './index'; + +const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { + element: 'span' + /* + draft uses
by default which we don't really like, so we're using + this is probably not a good idea since is not a block level element but + we're trying to fix things in contentStateToHTML below + */ +}); + +const STYLES = { + BOLD: 'strong', + CODE: 'code', + ITALIC: 'em', + STRIKETHROUGH: 's', + UNDERLINE: 'u' +}; + +const MARKDOWN_REGEX = { + LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, + ITALIC: /([\*_])([\w\s]+?)\1/g, + BOLD: /([\*_])\1([\w\s]+?)\1\1/g +}; + +const USERNAME_REGEX = /@\S+:\S+/g; +const ROOM_REGEX = /#\S+:\S+/g; + +export function contentStateToHTML(contentState: ContentState): string { + return contentState.getBlockMap().map((block) => { + let elem = BLOCK_RENDER_MAP.get(block.getType()).element; + let content = []; + block.findStyleRanges( + () => true, // always return true => don't filter any ranges out + (start, end) => { + // map style names to elements + let tags = block.getInlineStyleAt(start).map(style => STYLES[style]).filter(style => !!style); + // combine them to get well-nested HTML + let open = tags.map(tag => `<${tag}>`).join(''); + let close = tags.map(tag => ``).reverse().join(''); + // and get the HTML representation of this styled range (this .substring() should never fail) + let text = block.getText().substring(start, end); + // http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/ + let div = document.createElement('div'); + div.appendChild(document.createTextNode(text)); + let safeText = div.innerHTML; + content.push(`${open}${safeText}${close}`); + } + ); + + let result = `<${elem}>${content.join('')}`; + + // dirty hack because we don't want block level tags by default, but breaks + if(elem === 'span') + result += '
'; + return result; + }).join(''); +} + +export function HTMLtoContentState(html: string): ContentState { + return ContentState.createFromBlockArray(convertFromHTML(html)); +} + +/** + * Returns a composite decorator which has access to provided scope. + */ +export function getScopedRTDecorators(scope: any): CompositeDecorator { + let MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + + let usernameDecorator = { + strategy: (contentBlock, callback) => { + findWithRegex(USERNAME_REGEX, contentBlock, callback); + }, + component: (props) => { + let member = scope.room.getMember(props.children[0].props.text); + // unused until we make these decorators immutable (autocomplete needed) + let name = member ? member.name : null; + let avatar = member ? : null; + return {avatar} {props.children}; + } + }; + let roomDecorator = { + strategy: (contentBlock, callback) => { + findWithRegex(ROOM_REGEX, contentBlock, callback); + }, + component: (props) => { + return {props.children}; + } + }; + + return [usernameDecorator, roomDecorator]; +} + +export function getScopedMDDecorators(scope: any): CompositeDecorator { + let markdownDecorators = ['BOLD', 'ITALIC'].map( + (style) => ({ + strategy: (contentBlock, callback) => { + return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); + }, + component: (props) => ( + + {props.children} + + ) + })); + + markdownDecorators.push({ + strategy: (contentBlock, callback) => { + return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback); + }, + component: (props) => ( + + {props.children} + + ) + }); + + return markdownDecorators; +} + +/** + * Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end) + * From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html + */ +function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) { + const text = contentBlock.getText(); + let matchArr, start; + while ((matchArr = regex.exec(text)) !== null) { + start = matchArr.index; + callback(start, start + matchArr[0].length); + } +} + +/** + * Passes rangeToReplace to modifyFn and replaces it in contentState with the result. + */ +export function modifyText(contentState: ContentState, rangeToReplace: SelectionState, + modifyFn: (text: string) => string, inlineStyle, entityKey): ContentState { + let getText = (key) => contentState.getBlockForKey(key).getText(), + startKey = rangeToReplace.getStartKey(), + startOffset = rangeToReplace.getStartOffset(), + endKey = rangeToReplace.getEndKey(), + endOffset = rangeToReplace.getEndOffset(), + text = ""; + + + for(let currentKey = startKey; + currentKey && currentKey !== endKey; + currentKey = contentState.getKeyAfter(currentKey)) { + let blockText = getText(currentKey); + text += blockText.substring(startOffset, blockText.length); + + // from now on, we'll take whole blocks + startOffset = 0; + } + + // add remaining part of last block + text += getText(endKey).substring(startOffset, endOffset); + + return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); +} diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 9bb1388e76..305994aa0e 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -112,4 +112,12 @@ module.exports = { append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address }); }, + + isFeatureEnabled: function(feature: string): boolean { + return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true'; + }, + + setFeatureEnabled: function(feature: string, enabled: boolean) { + localStorage.setItem(`mx_labs_feature_${feature}`, enabled); + } }; diff --git a/src/Velociraptor.js b/src/Velociraptor.js index 0abf34b230..f45925867f 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -1,6 +1,6 @@ var React = require('react'); var ReactDom = require('react-dom'); -var Velocity = require('velocity-animate'); +var Velocity = require('velocity-vector'); /** * The Velociraptor contains components and animates transitions with velocity. @@ -117,7 +117,8 @@ module.exports = React.createClass({ // and the FAQ entry, "Preventing memory leaks when // creating/destroying large numbers of elements" // (https://github.com/julianshapiro/velocity/issues/47) - Velocity.Utilities.removeData(this.nodes[k]); + var domNode = ReactDom.findDOMNode(this.nodes[k]); + Velocity.Utilities.removeData(domNode); } this.nodes[k] = node; }, diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js index c85aa254fa..168b0b14af 100644 --- a/src/VelocityBounce.js +++ b/src/VelocityBounce.js @@ -1,4 +1,4 @@ -var Velocity = require('velocity-animate'); +var Velocity = require('velocity-vector'); // courtesy of https://github.com/julianshapiro/velocity/issues/283 // We only use easeOutBounce (easeInBounce is just sort of nonsensical) diff --git a/src/component-index.js b/src/component-index.js index 967cc5d685..4aa0efe21f 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -79,11 +79,13 @@ module.exports.components['views.rooms.EntityTile'] = require('./components/view module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.InviteMemberList'] = require('./components/views/rooms/InviteMemberList'); module.exports.components['views.rooms.LinkPreviewWidget'] = require('./components/views/rooms/LinkPreviewWidget'); +module.exports.components['views.rooms.MemberDeviceInfo'] = require('./components/views/rooms/MemberDeviceInfo'); module.exports.components['views.rooms.MemberInfo'] = require('./components/views/rooms/MemberInfo'); module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList'); module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile'); module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer'); module.exports.components['views.rooms.MessageComposerInput'] = require('./components/views/rooms/MessageComposerInput'); +module.exports.components['views.rooms.MessageComposerInputOld'] = require('./components/views/rooms/MessageComposerInputOld'); module.exports.components['views.rooms.PresenceLabel'] = require('./components/views/rooms/PresenceLabel'); module.exports.components['views.rooms.ReadReceiptMarker'] = require('./components/views/rooms/ReadReceiptMarker'); module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 9aad4e72de..08ef4cab9a 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -38,11 +38,13 @@ var MatrixTools = require('../../MatrixTools'); var linkifyMatrix = require("../../linkify-matrix"); var KeyCode = require('../../KeyCode'); +var createRoom = require("../../createRoom"); + module.exports = React.createClass({ displayName: 'MatrixChat', propTypes: { - config: React.PropTypes.object.isRequired, + config: React.PropTypes.object, ConferenceHandler: React.PropTypes.any, onNewScreen: React.PropTypes.func, registrationUrl: React.PropTypes.string, @@ -63,6 +65,13 @@ module.exports = React.createClass({ getInitialState: function() { var s = { + // If we are viewing a room by alias, this contains the alias + currentRoomAlias: null, + + // The ID of the room we're viewing. This is either populated directly + // in the case where we view a room by ID or by RoomView when it resolves + // what ID an alias points at. + currentRoomId: null, logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials), collapse_lhs: false, collapse_rhs: false, @@ -85,7 +94,8 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - startingQueryParams: {} + startingQueryParams: {}, + config: {}, }; }, @@ -98,10 +108,9 @@ module.exports = React.createClass({ else if (window.localStorage && window.localStorage.getItem("mx_hs_url")) { return window.localStorage.getItem("mx_hs_url"); } - else if (this.props.config) { - return this.props.config.default_hs_url + else { + return this.props.config.default_hs_url || "https://matrix.org"; } - return "https://matrix.org"; }, getFallbackHsUrl: function() { @@ -117,10 +126,9 @@ module.exports = React.createClass({ else if (window.localStorage && window.localStorage.getItem("mx_is_url")) { return window.localStorage.getItem("mx_is_url"); } - else if (this.props.config) { - return this.props.config.default_is_url + else { + return this.props.config.default_is_url || "https://vector.im" } - return "https://matrix.org"; }, componentWillMount: function() { @@ -393,6 +401,10 @@ module.exports = React.createClass({ }); break; case 'view_room': + // Takes either a room ID or room alias: if switching to a room the client is already + // known to be in (eg. user clicks on a room in the recents panel), supply the ID + // If the user is clicking on a room in the context of the alias being presented + // to them, supply the room alias. If both are supplied, the room ID will be ignored. this._viewRoom( payload.room_id, payload.room_alias, payload.show_settings, payload.event_id, payload.third_party_invite, payload.oob_data @@ -406,7 +418,7 @@ module.exports = React.createClass({ ); var roomIndex = -1; for (var i = 0; i < allRooms.length; ++i) { - if (allRooms[i].roomId == this.state.currentRoom) { + if (allRooms[i].roomId == this.state.currentRoomId) { roomIndex = i; break; } @@ -424,42 +436,6 @@ module.exports = React.createClass({ this._viewRoom(allRooms[roomIndex].roomId); } break; - case 'view_room_alias': - if (!this.state.logged_in) { - this.starting_room_alias_payload = payload; - // Login is the default screen, so we'd do this anyway, - // but this will set the URL bar appropriately. - dis.dispatch({ action: 'start_login' }); - return; - } - - var foundRoom = MatrixTools.getRoomForAlias( - MatrixClientPeg.get().getRooms(), payload.room_alias - ); - if (foundRoom) { - dis.dispatch({ - action: 'view_room', - room_id: foundRoom.roomId, - room_alias: payload.room_alias, - event_id: payload.event_id, - third_party_invite: payload.third_party_invite, - oob_data: payload.oob_data, - }); - return; - } - // resolve the alias and *then* view it - MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias).done( - function(result) { - dis.dispatch({ - action: 'view_room', - room_id: result.room_id, - room_alias: payload.room_alias, - event_id: payload.event_id, - third_party_invite: payload.third_party_invite, - oob_data: payload.oob_data, - }); - }); - break; case 'view_user_settings': this._setPage(this.PageTypes.UserSettings); this.notifyNewScreen('settings'); @@ -468,49 +444,7 @@ module.exports = React.createClass({ //this._setPage(this.PageTypes.CreateRoom); //this.notifyNewScreen('new'); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); - var Loader = sdk.getComponent("elements.Spinner"); - var modal = Modal.createDialog(Loader); - - if (MatrixClientPeg.get().isGuest()) { - Modal.createDialog(NeedToRegisterDialog, { - title: "Please Register", - description: "Guest users can't create new rooms. Please register to create room and start a chat." - }); - return; - } - - // XXX: FIXME: deduplicate this with MemberInfo's 'start chat' impl - MatrixClientPeg.get().createRoom({ - preset: "private_chat", - // Allow guests by default since the room is private and they'd - // need an invite. This means clicking on a 3pid invite email can - // actually drop you right in to a chat. - initial_state: [ - { - content: { - guest_access: 'can_join' - }, - type: 'm.room.guest_access', - state_key: '', - visibility: 'private', - } - ], - }).done(function(res) { - modal.close(); - dis.dispatch({ - action: 'view_room', - room_id: res.room_id, - // show_settings: true, - }); - }, function(err) { - modal.close(); - Modal.createDialog(ErrorDialog, { - title: "Failed to create room", - description: err.toString() - }); - }); + createRoom().done(); break; case 'view_room_directory': this._setPage(this.PageTypes.RoomDirectory); @@ -575,16 +509,19 @@ module.exports = React.createClass({ this.focusComposer = true; var newState = { - currentRoom: roomId, - currentRoomAlias: roomAlias, initialEventId: eventId, highlightedEventId: eventId, initialEventPixelOffset: undefined, page_type: this.PageTypes.RoomView, thirdPartyInvite: thirdPartyInvite, roomOobData: oob_data, + currentRoomAlias: roomAlias, }; + if (!roomAlias) { + newState.currentRoomId = roomId; + } + // if we aren't given an explicit event id, look for one in the // scrollStateMap. if (!eventId) { @@ -604,7 +541,7 @@ module.exports = React.createClass({ var presentedId = roomAlias || roomId; var room = MatrixClientPeg.get().getRoom(roomId); if (room) { - var theAlias = MatrixTools.getCanonicalAliasForRoom(room); + var theAlias = MatrixTools.getDisplayAliasForRoom(room); if (theAlias) presentedId = theAlias; // No need to do this given RoomView triggers it itself... @@ -677,13 +614,13 @@ module.exports = React.createClass({ dis.dispatch(self.starting_room_alias_payload); delete self.starting_room_alias_payload; } else if (!self.state.page_type) { - if (!self.state.currentRoom) { + if (!self.state.currentRoomId) { var firstRoom = null; if (cli.getRooms() && cli.getRooms().length) { firstRoom = RoomListSorter.mostRecentActivityFirst( cli.getRooms() )[0].roomId; - self.setState({ready: true, currentRoom: firstRoom, page_type: self.PageTypes.RoomView}); + self.setState({ready: true, currentRoomId: firstRoom, page_type: self.PageTypes.RoomView}); } else { self.setState({ready: true, page_type: self.PageTypes.RoomDirectory}); } @@ -693,10 +630,10 @@ module.exports = React.createClass({ // we notifyNewScreen now because now the room will actually be displayed, // and (mostly) now we can get the correct alias. - var presentedId = self.state.currentRoom; - var room = MatrixClientPeg.get().getRoom(self.state.currentRoom); + var presentedId = self.state.currentRoomId; + var room = MatrixClientPeg.get().getRoom(self.state.currentRoomId); if (room) { - var theAlias = MatrixTools.getCanonicalAliasForRoom(room); + var theAlias = MatrixTools.getDisplayAliasForRoom(room); if (theAlias) presentedId = theAlias; } @@ -861,22 +798,28 @@ module.exports = React.createClass({ inviterName: params.inviter_name, }; + var payload = { + action: 'view_room', + event_id: eventId, + third_party_invite: third_party_invite, + oob_data: oob_data, + }; if (roomString[0] == '#') { - dis.dispatch({ - action: 'view_room_alias', - room_alias: roomString, - event_id: eventId, - third_party_invite: third_party_invite, - oob_data: oob_data, - }); + payload.room_alias = roomString; } else { - dis.dispatch({ - action: 'view_room', - room_id: roomString, - event_id: eventId, - third_party_invite: third_party_invite, - oob_data: oob_data, - }); + payload.room_id = roomString; + } + + // we can't view a room unless we're logged in + // (a guest account is fine) + if (!this.state.logged_in) { + this.starting_room_alias_payload = payload; + // Login is the default screen, so we'd do this anyway, + // but this will set the URL bar appropriately. + dis.dispatch({ action: 'start_login' }); + return; + } else { + dis.dispatch(payload); } } else { @@ -892,7 +835,7 @@ module.exports = React.createClass({ onAliasClick: function(event, alias) { event.preventDefault(); - dis.dispatch({action: 'view_room_alias', room_alias: alias}); + dis.dispatch({action: 'view_room', room_alias: alias}); }, onUserClick: function(event, userId) { @@ -1038,10 +981,10 @@ module.exports = React.createClass({ onUserSettingsClose: function() { // XXX: use browser history instead to find the previous room? // or maintain a this.state.pageHistory in _setPage()? - if (this.state.currentRoom) { + if (this.state.currentRoomId) { dis.dispatch({ action: 'view_room', - room_id: this.state.currentRoom, + room_id: this.state.currentRoomId, }); } else { @@ -1051,6 +994,13 @@ module.exports = React.createClass({ } }, + onRoomIdResolved: function(room_id) { + // It's the RoomView's resposibility to look up room aliases, but we need the + // ID to pass into things like the Member List, so the Room View tells us when + // its done that resolution so we can display things that take a room ID. + this.setState({currentRoomId: room_id}); + }, + render: function() { var LeftPanel = sdk.getComponent('structures.LeftPanel'); var RoomView = sdk.getComponent('structures.RoomView'); @@ -1081,20 +1031,21 @@ module.exports = React.createClass({ page_element = ( ); - right_panel = + right_panel = break; case this.PageTypes.UserSettings: - page_element = + page_element = right_panel = break; case this.PageTypes.CreateRoom: @@ -1127,7 +1078,7 @@ module.exports = React.createClass({
{topBar}
- +
{page_element}
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 16b4892bc0..c8e878118b 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -86,6 +86,10 @@ module.exports = React.createClass({ // to manage its animations this._readReceiptMap = {}; + // Remember the read marker ghost node so we can do the cleanup that + // Velocity requires + this._readMarkerGhostNode = null; + this._isMounted = true; }, @@ -422,9 +426,16 @@ module.exports = React.createClass({ }, _startAnimation: function(ghostNode) { - Velocity(ghostNode, {opacity: '0', width: '10%'}, - {duration: 400, easing: 'easeInSine', - delay: 1000}); + if (this._readMarkerGhostNode) { + Velocity.Utilities.removeData(this._readMarkerGhostNode); + } + this._readMarkerGhostNode = ghostNode; + + if (ghostNode) { + Velocity(ghostNode, {opacity: '0', width: '10%'}, + {duration: 400, easing: 'easeInSine', + delay: 1000}); + } }, _getReadMarkerGhostTile: function() { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 77080b5a75..e1b4c00175 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -39,6 +39,7 @@ var dis = require("../../dispatcher"); var Tinter = require("../../Tinter"); var rate_limited_func = require('../../ratelimitedfunc'); var ObjectUtils = require('../../ObjectUtils'); +var MatrixTools = require('../../MatrixTools'); var DEBUG = false; @@ -54,16 +55,17 @@ module.exports = React.createClass({ propTypes: { ConferenceHandler: React.PropTypes.any, - // the ID for this room (or, if we don't know it, an alias for it) - // - // XXX: if this is an alias, we will display a 'join' dialogue, - // regardless of whether we are already a member, or if the room is - // peekable. Currently there is a big mess, where at least four - // different components (RoomView, MatrixChat, RoomDirectory, - // SlashCommands) have logic for turning aliases into rooms, and each - // of them do it differently and have different edge cases. + // Either a room ID or room alias for the room to display. + // If the room is being displayed as a result of the user clicking + // on a room alias, the alias should be supplied. Otherwise, a room + // ID should be supplied. roomAddress: React.PropTypes.string.isRequired, + // If a room alias is passed to roomAddress, a function can be + // provided here that will be called with the ID of the room + // once it has been resolved. + onRoomIdResolved: React.PropTypes.func, + // An object representing a third party invite to join this room // Fields: // * inviteSignUrl (string) The URL used to join this room from an email invite @@ -100,17 +102,17 @@ module.exports = React.createClass({ }, getInitialState: function() { - var room = MatrixClientPeg.get().getRoom(this.props.roomAddress); return { - room: room, - roomLoading: !room, + room: null, + roomId: null, + roomLoading: true, editingRoomSettings: false, uploadingRoomSettings: false, numUnreadMessages: 0, draggingFile: false, searching: false, searchResults: null, - hasUnsentMessages: this._hasUnsentMessages(room), + hasUnsentMessages: false, callState: null, guestsCanJoin: false, canPeek: false, @@ -142,6 +144,39 @@ module.exports = React.createClass({ } }); + if (this.props.roomAddress[0] == '#') { + // we always look up the alias from the directory server: + // we want the room that the given alias is pointing to + // right now. We may have joined that alias before but there's + // no guarantee the alias hasn't subsequently been remapped. + MatrixClientPeg.get().getRoomIdForAlias(this.props.roomAddress).done((result) => { + if (this.props.onRoomIdResolved) { + this.props.onRoomIdResolved(result.room_id); + } + var room = MatrixClientPeg.get().getRoom(result.room_id); + this.setState({ + room: room, + roomId: result.room_id, + roomLoading: !room, + hasUnsentMessages: this._hasUnsentMessages(room), + }, this._updatePeeking); + }, (err) => { + this.setState({ + roomLoading: false, + }); + }); + } else { + var room = MatrixClientPeg.get().getRoom(this.props.roomAddress); + this.setState({ + roomId: this.props.roomAddress, + room: room, + roomLoading: !room, + hasUnsentMessages: this._hasUnsentMessages(room), + }, this._updatePeeking); + } + }, + + _updatePeeking: function() { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can publicly join or were invited to. (we can /join) @@ -149,10 +184,13 @@ module.exports = React.createClass({ // We can't try to /join because this may implicitly accept invites (!) // We can /peek though. If it fails then we present the join UI. If it // succeeds then great, show the preview (but we still may be able to /join!). - if (!this.state.room) { - console.log("Attempting to peek into room %s", this.props.roomAddress); + // Note that peeking works by room ID and room ID only, as opposed to joining + // which must be by alias or invite wherever possible (peeking currently does + // not work over federation). + if (!this.state.room && this.state.roomId) { + console.log("Attempting to peek into room %s", this.state.roomId); - MatrixClientPeg.get().peekInRoom(this.props.roomAddress).then((room) => { + MatrixClientPeg.get().peekInRoom(this.state.roomId).then((room) => { this.setState({ room: room, roomLoading: false, @@ -171,7 +209,7 @@ module.exports = React.createClass({ throw err; } }).done(); - } else { + } else if (this.state.room) { MatrixClientPeg.get().stopPeeking(); this._onRoomLoaded(this.state.room); } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index e56e5d9d87..7fcb81a60c 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -20,18 +20,36 @@ var MatrixClientPeg = require("../../MatrixClientPeg"); var Modal = require('../../Modal'); var dis = require("../../dispatcher"); var q = require('q'); -var version = require('../../../package.json').version; +var package_json = require('../../../package.json'); var UserSettingsStore = require('../../UserSettingsStore'); var GeminiScrollbar = require('react-gemini-scrollbar'); var Email = require('../../email'); var AddThreepid = require('../../AddThreepid'); +const LABS_FEATURES = [ + { + name: 'Rich Text Editor', + id: 'rich_text_editor' + }, + { + name: 'End-to-End Encryption', + id: 'e2e_encryption' + } +]; + +// if this looks like a release, use the 'version' from package.json; else use +// the git sha. +const REACT_SDK_VERSION = + 'dist' in package_json ? package_json.version : package_json.gitHead || ""; + module.exports = React.createClass({ displayName: 'UserSettings', propTypes: { version: React.PropTypes.string, - onClose: React.PropTypes.func + onClose: React.PropTypes.func, + // The brand string given when creating email pushers + brand: React.PropTypes.string, }, getDefaultProps: function() { @@ -44,7 +62,6 @@ module.exports = React.createClass({ return { avatarUrl: null, threePids: [], - clientVersion: version, phase: "UserSettings.LOADING", // LOADING, DISPLAY email_add_pending: false, }; @@ -244,6 +261,27 @@ module.exports = React.createClass({ }); }, + _renderDeviceInfo: function() { + if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { + return null; + } + + var client = MatrixClientPeg.get(); + var deviceId = client.deviceId; + var olmKey = client.getDeviceEd25519Key() || ""; + return ( +
+

Cryptography

+
+
    +
  • Device ID: {deviceId}
  • +
  • Device key: {olmKey}
  • +
+
+
+ ); + }, + render: function() { var self = this; var Loader = sdk.getComponent("elements.Spinner"); @@ -333,11 +371,35 @@ module.exports = React.createClass({

Notifications

- +
); } + this._renderLabs = function () { + let features = LABS_FEATURES.map(feature => ( +
+ UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked)} /> + +
+ )); + return ( +
+

Labs

+ +
+

These are experimental features that may break in unexpected ways. Use with caution.

+ {features} +
+
+ ) + }; + return (
@@ -390,6 +452,10 @@ module.exports = React.createClass({ {notification_area} + {this._renderDeviceInfo()} + + {this._renderLabs()} +

Advanced

@@ -403,7 +469,7 @@ module.exports = React.createClass({ Identity Server is { MatrixClientPeg.get().getIdentityServerUrl() }
- matrix-react-sdk version: {this.state.clientVersion}
+ matrix-react-sdk version: {REACT_SDK_VERSION}
vector-web version: {this.props.version}
diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 83bd1ab17c..a172d77bb4 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -17,8 +17,8 @@ limitations under the License. 'use strict'; var React = require('react'); -var Velocity = require('velocity-animate'); -require('velocity-ui-pack'); +var Velocity = require('velocity-vector'); +require('velocity-vector/velocity.ui'); var sdk = require('../../../index'); var Email = require('../../../email'); var Modal = require("../../../Modal"); diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 7db8af9312..ff02139d87 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -128,16 +128,24 @@ module.exports = React.createClass({ }, getInitialState: function() { - return {menu: false, allReadAvatars: false}; + return {menu: false, allReadAvatars: false, verified: null}; }, componentWillMount: function() { // don't do RR animations until we are mounted this._suppressReadReceiptAnimation = true; + this._verifyEvent(this.props.mxEvent); }, componentDidMount: function() { this._suppressReadReceiptAnimation = false; + MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified); + }, + + componentWillReceiveProps: function (nextProps) { + if (nextProps.mxEvent !== this.props.mxEvent) { + this._verifyEvent(nextProps.mxEvent); + } }, shouldComponentUpdate: function (nextProps, nextState) { @@ -152,6 +160,31 @@ module.exports = React.createClass({ return false; }, + componentWillUnmount: function() { + var client = MatrixClientPeg.get(); + if (client) { + client.removeListener("deviceVerified", this.onDeviceVerified); + } + }, + + onDeviceVerified: function(userId, device) { + if (userId == this.props.mxEvent.getSender()) { + this._verifyEvent(this.props.mxEvent); + } + }, + + _verifyEvent: function(mxEvent) { + var verified = null; + + if (mxEvent.isEncrypted()) { + verified = MatrixClientPeg.get().isEventSenderVerified(mxEvent); + } + + this.setState({ + verified: verified + }); + }, + _propsEqual: function(objA, objB) { var keysA = Object.keys(objA); var keysB = Object.keys(objB); @@ -346,6 +379,8 @@ module.exports = React.createClass({ mx_EventTile_last: this.props.last, mx_EventTile_contextual: this.props.contextual, menu: this.state.menu, + mx_EventTile_verified: this.state.verified == true, + mx_EventTile_unverified: this.state.verified == false, }); var timestamp = diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js new file mode 100644 index 0000000000..ebc2ab1c32 --- /dev/null +++ b/src/components/views/rooms/MemberDeviceInfo.js @@ -0,0 +1,68 @@ +/* +Copyright 2016 OpenMarket 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. +*/ + +var React = require('react'); +var MatrixClientPeg = require("../../../MatrixClientPeg"); + +module.exports = React.createClass({ + displayName: 'MemberDeviceInfo', + propTypes: { + userId: React.PropTypes.string.isRequired, + device: React.PropTypes.object.isRequired, + }, + + onVerifyClick: function() { + MatrixClientPeg.get().setDeviceVerified( + this.props.userId, this.props.device.id, true + ); + }, + + onUnverifyClick: function() { + MatrixClientPeg.get().setDeviceVerified( + this.props.userId, this.props.device.id, false + ); + }, + + render: function() { + var indicator = null, button = null; + if (this.props.device.verified) { + indicator = ( +
+ ); + button = ( +
+ Unverify +
+ ); + } else { + button = ( +
+ Verify +
+ ); + } + return ( +
+
{this.props.device.id}
+
{this.props.device.key}
+ {indicator} + {button} +
+ ); + }, +}); diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index ba4a3734f5..97cfecc9e1 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -30,27 +30,107 @@ var MatrixClientPeg = require("../../../MatrixClientPeg"); var dis = require("../../../dispatcher"); var Modal = require("../../../Modal"); var sdk = require('../../../index'); +var UserSettingsStore = require('../../../UserSettingsStore'); +var createRoom = require('../../../createRoom'); module.exports = React.createClass({ displayName: 'MemberInfo', + propTypes: { + member: React.PropTypes.object.isRequired, + onFinished: React.PropTypes.func, + }, + getDefaultProps: function() { return { onFinished: function() {} }; }, - componentDidMount: function() { - // work out the current state - if (this.props.member) { - var memberState = this._calculateOpsPermissions(this.props.member); - this.setState(memberState); + getInitialState: function() { + return { + can: { + kick: false, + ban: false, + mute: false, + modifyLevel: false + }, + muted: false, + isTargetMod: false, + updating: 0, + devicesLoading: true, + devices: null, } }, + + componentWillMount: function() { + this._cancelDeviceList = null; + }, + + componentDidMount: function() { + this._updateStateForNewMember(this.props.member); + MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified); + }, + componentWillReceiveProps: function(newProps) { - var memberState = this._calculateOpsPermissions(newProps.member); - this.setState(memberState); + if (this.props.member.userId != newProps.member.userId) { + this._updateStateForNewMember(newProps.member); + } + }, + + componentWillUnmount: function() { + var client = MatrixClientPeg.get(); + if (client) { + client.removeListener("deviceVerified", this.onDeviceVerified); + } + if (this._cancelDeviceList) { + this._cancelDeviceList(); + } + }, + + onDeviceVerified: function(userId, device) { + if (userId == this.props.member.userId) { + // no need to re-download the whole thing; just update our copy of + // the list. + var devices = MatrixClientPeg.get().listDeviceKeys(userId); + this.setState({devices: devices}); + } + }, + + _updateStateForNewMember: function(member) { + var newState = this._calculateOpsPermissions(member); + newState.devicesLoading = true; + newState.devices = null; + this.setState(newState); + + if (this._cancelDeviceList) { + this._cancelDeviceList(); + this._cancelDeviceList = null; + } + + this._downloadDeviceList(member); + }, + + _downloadDeviceList: function(member) { + var cancelled = false; + this._cancelDeviceList = function() { cancelled = true; } + + var client = MatrixClientPeg.get(); + var self = this; + client.downloadKeys([member.userId], true).finally(function() { + self._cancelDeviceList = null; + }).done(function() { + if (cancelled) { + // we got cancelled - presumably a different user now + return; + } + var devices = client.listDeviceKeys(member.userId); + self.setState({devicesLoading: false, devices: devices}); + }, function(err) { + console.log("Error downloading devices", err); + self.setState({devicesLoading: false}); + }); }, onKick: function() { @@ -315,51 +395,15 @@ module.exports = React.createClass({ this.props.onFinished(); } else { - if (MatrixClientPeg.get().isGuest()) { - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); - Modal.createDialog(NeedToRegisterDialog, { - title: "Please Register", - description: "Guest users can't create new rooms. Please register to create room and start a chat." - }); - self.props.onFinished(); - return; - } - self.setState({ updating: self.state.updating + 1 }); - MatrixClientPeg.get().createRoom({ - // XXX: FIXME: deduplicate this with "view_create_room" in MatrixChat - invite: [this.props.member.userId], - preset: "private_chat", - // Allow guests by default since the room is private and they'd - // need an invite. This means clicking on a 3pid invite email can - // actually drop you right in to a chat. - initial_state: [ - { - content: { - guest_access: 'can_join' - }, - type: 'm.room.guest_access', - state_key: '', - visibility: 'private', - } - ], - }).then( - function(res) { - dis.dispatch({ - action: 'view_room', - room_id: res.room_id - }); - self.props.onFinished(); - }, function(err) { - Modal.createDialog(ErrorDialog, { - title: "Failure to start chat", - description: err.message - }); - self.props.onFinished(); - } - ).finally(()=>{ + createRoom({ + createOpts: { + invite: [this.props.member.userId], + }, + }).finally(function() { + self.props.onFinished(); self.setState({ updating: self.state.updating - 1 }); - }); + }).done(); } }, @@ -371,20 +415,6 @@ module.exports = React.createClass({ this.props.onFinished(); }, - getInitialState: function() { - return { - can: { - kick: false, - ban: false, - mute: false, - modifyLevel: false - }, - muted: false, - isTargetMod: false, - updating: 0, - } - }, - _calculateOpsPermissions: function(member) { var defaultPerms = { can: {}, @@ -476,6 +506,40 @@ module.exports = React.createClass({ Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); }, + _renderDevices: function() { + if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { + return null; + } + + var devices = this.state.devices; + var MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo'); + var Spinner = sdk.getComponent("elements.Spinner"); + + var devComponents; + if (this.state.devicesLoading) { + // still loading + devComponents = ; + } else if (devices === null) { + devComponents = "Unable to load device list"; + } else if (devices.length === 0) { + devComponents = "No registered devices"; + } else { + devComponents = []; + for (var i = 0; i < devices.length; i++) { + devComponents.push(); + } + } + + return ( +
+

Devices

+ {devComponents} +
+ ); + }, + render: function() { var startChat, kickButton, banButton, muteButton, giveModButton, spinner; if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { @@ -552,6 +616,8 @@ module.exports = React.createClass({ { startChat } + { this._renderDevices() } + { adminTools } { spinner } @@ -559,4 +625,3 @@ module.exports = React.createClass({ ); } }); - diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 18d138f013..eaee8205e4 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -21,6 +21,8 @@ var Modal = require('../../../Modal'); var sdk = require('../../../index'); var dis = require('../../../dispatcher'); +import UserSettingsStore from '../../../UserSettingsStore'; + module.exports = React.createClass({ displayName: 'MessageComposer', @@ -131,7 +133,8 @@ module.exports = React.createClass({ var uploadInputStyle = {display: 'none'}; var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var TintableSvg = sdk.getComponent("elements.TintableSvg"); - var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput"); + var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" + + (UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old")); var controls = []; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 733d9e6056..8a0ee7d8a8 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -13,7 +13,7 @@ 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. */ -var React = require("react"); +import React from 'react'; var marked = require("marked"); marked.setOptions({ @@ -27,6 +27,12 @@ marked.setOptions({ smartypants: false }); +import {Editor, EditorState, RichUtils, CompositeDecorator, + convertFromRaw, convertToRaw, Modifier, EditorChangeType, + getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; + +import {stateToMarkdown} from 'draft-js-export-markdown'; + var MatrixClientPeg = require("../../../MatrixClientPeg"); var SlashCommands = require("../../../SlashCommands"); var Modal = require("../../../Modal"); @@ -36,10 +42,13 @@ var sdk = require('../../../index'); var dis = require("../../../dispatcher"); var KeyCode = require("../../../KeyCode"); -var TYPING_USER_TIMEOUT = 10000; -var TYPING_SERVER_TIMEOUT = 30000; -var MARKDOWN_ENABLED = true; +import * as RichText from '../../../RichText'; +const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; + +const KEY_M = 77; + +// FIXME Breaks markdown with multiple paragraphs, since it only strips first and last

function mdownToHtml(mdown) { var html = marked(mdown) || ""; html = html.trim(); @@ -56,29 +65,63 @@ function mdownToHtml(mdown) { /* * The textInput part of the MessageComposer */ -module.exports = React.createClass({ - displayName: 'MessageComposerInput', +export default class MessageComposerInput extends React.Component { + constructor(props, context) { + super(props, context); + this.onAction = this.onAction.bind(this); + this.onInputClick = this.onInputClick.bind(this); + this.handleReturn = this.handleReturn.bind(this); + this.handleKeyCommand = this.handleKeyCommand.bind(this); + this.onChange = this.onChange.bind(this); - statics: { - // the height we limit the composer to - MAX_HEIGHT: 100, - }, + let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); + if(isRichtextEnabled == null) { + isRichtextEnabled = 'true'; + } + isRichtextEnabled = isRichtextEnabled === 'true'; - propTypes: { - tabComplete: React.PropTypes.any, + this.state = { + isRichtextEnabled: isRichtextEnabled, + editorState: null + }; - // a callback which is called when the height of the composer is - // changed due to a change in content. - onResize: React.PropTypes.func, + // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled + this.state.editorState = this.createEditorState(); - // js-sdk Room object - room: React.PropTypes.object.isRequired, - }, + this.client = MatrixClientPeg.get(); + } - componentWillMount: function() { - this.oldScrollHeight = 0; - this.markdownEnabled = MARKDOWN_ENABLED; - var self = this; + static getKeyBinding(e: SyntheticKeyboardEvent): string { + // C-m => Toggles between rich text and markdown modes + if(e.keyCode == KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { + return 'toggle-mode'; + } + + return getDefaultKeyBinding(e); + } + + /** + * "Does the right thing" to create an EditorState, based on: + * - whether we've got rich text mode enabled + * - contentState was passed in + */ + createEditorState(richText: boolean, contentState: ?ContentState): EditorState { + let decorators = richText ? RichText.getScopedRTDecorators(this.props) : + RichText.getScopedMDDecorators(this.props), + compositeDecorator = new CompositeDecorator(decorators); + + let editorState = null; + if (contentState) { + editorState = EditorState.createWithContent(contentState, compositeDecorator); + } else { + editorState = EditorState.createEmpty(compositeDecorator); + } + + return EditorState.moveFocusToEnd(editorState); + } + + componentWillMount() { + const component = this; this.sentHistory = { // The list of typed messages. Index 0 is more recent data: [], @@ -96,7 +139,7 @@ module.exports = React.createClass({ this.element = element; this.position = -1; var storedData = window.sessionStorage.getItem( - "history_" + roomId + "mx_messagecomposer_history_" + roomId ); if (storedData) { this.data = JSON.parse(storedData); @@ -110,7 +153,7 @@ module.exports = React.createClass({ // store a message in the sent history this.data.unshift(text); window.sessionStorage.setItem( - "history_" + this.roomId, + "mx_messagecomposer_history_" + this.roomId, JSON.stringify(this.data) ); // reset history position @@ -149,7 +192,6 @@ module.exports = React.createClass({ this.element.value = this.originalText; } - self.resizeInput(); return true; }, @@ -157,76 +199,68 @@ module.exports = React.createClass({ // save the currently entered text in order to restore it later. // NB: This isn't 'originalText' because we want to restore // sent history items too! - var text = this.element.value; - window.sessionStorage.setItem("input_" + this.roomId, text); + let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent())); + window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON); }, setLastTextEntry: function() { - var text = window.sessionStorage.getItem("input_" + this.roomId); - if (text) { - this.element.value = text; - self.resizeInput(); + let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); + if (contentJSON) { + let content = convertFromRaw(JSON.parse(contentJSON)); + component.setState({ + editorState: component.createEditorState(component.state.isRichtextEnabled, content) + }); } } }; - }, + } - componentDidMount: function() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); this.sentHistory.init( - this.refs.textarea, + this.refs.editor, this.props.room.roomId ); - this.resizeInput(); - if (this.props.tabComplete) { - this.props.tabComplete.setTextArea(this.refs.textarea); - } - }, + // this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon + // if (this.props.tabComplete) { + // this.props.tabComplete.setEditor(this.refs.editor); + // } + } - componentWillUnmount: function() { + componentWillUnmount() { dis.unregister(this.dispatcherRef); this.sentHistory.saveLastTextEntry(); - }, + } + + onAction(payload) { + var editor = this.refs.editor; - onAction: function(payload) { - var textarea = this.refs.textarea; switch (payload.action) { case 'focus_composer': - textarea.focus(); + editor.focus(); break; - case 'insert_displayname': - if (textarea.value.length) { - var left = textarea.value.substring(0, textarea.selectionStart); - var right = textarea.value.substring(textarea.selectionEnd); - if (right.length) { - left += payload.displayname; - } - else { - left = left.replace(/( ?)$/, " " + payload.displayname); - } - textarea.value = left + right; - textarea.focus(); - textarea.setSelectionRange(left.length, left.length); - } - else { - textarea.value = payload.displayname + ": "; - textarea.focus(); - } - break; - } - }, - onKeyDown: function (ev) { - if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) { - var input = this.refs.textarea.value; - if (input.length === 0) { - ev.preventDefault(); - return; - } - this.sentHistory.push(input); - this.onEnter(ev); + // TODO change this so we insert a complete user alias + + case 'insert_displayname': + if (this.state.editorState.getCurrentContent().hasText()) { + console.log(payload); + let contentState = Modifier.replaceText( + this.state.editorState.getCurrentContent(), + this.state.editorState.getSelection(), + payload.displayname + ); + this.setState({ + editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters') + }); + editor.focus(); + } + break; } - else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { + } + + onKeyDown(ev) { + if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { var oldSelectionStart = this.refs.textarea.selectionStart; // Remember the keyCode because React will recycle the synthetic event var keyCode = ev.keyCode; @@ -235,78 +269,167 @@ module.exports = React.createClass({ setTimeout(() => { if (this.refs.textarea.selectionStart == oldSelectionStart) { this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); - this.resizeInput(); } }, 0); } + } - if (this.props.tabComplete) { - this.props.tabComplete.onKeyDown(ev); + onTypingActivity() { + this.isTyping = true; + if (!this.userTypingTimer) { + this.sendTyping(true); } + this.startUserTypingTimer(); + this.startServerTypingTimer(); + } + onFinishedTyping() { + this.isTyping = false; + this.sendTyping(false); + this.stopUserTypingTimer(); + this.stopServerTypingTimer(); + } + + startUserTypingTimer() { + this.stopUserTypingTimer(); var self = this; - setTimeout(function() { - if (self.refs.textarea && self.refs.textarea.value != '') { - self.onTypingActivity(); - } else { - self.onFinishedTyping(); - } - }, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :( - }, + this.userTypingTimer = setTimeout(function() { + self.isTyping = false; + self.sendTyping(self.isTyping); + self.userTypingTimer = null; + }, TYPING_USER_TIMEOUT); + } - resizeInput: function() { - // scrollHeight is at least equal to clientHeight, so we have to - // temporarily crimp clientHeight to 0 to get an accurate scrollHeight value - this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS - var newHeight = Math.min(this.refs.textarea.scrollHeight, - this.constructor.MAX_HEIGHT); - this.refs.textarea.style.height = Math.ceil(newHeight) + "px"; - this.oldScrollHeight = this.refs.textarea.scrollHeight; - - if (this.props.onResize) { - // kick gemini-scrollbar to re-layout - this.props.onResize(); + stopUserTypingTimer() { + if (this.userTypingTimer) { + clearTimeout(this.userTypingTimer); + this.userTypingTimer = null; } - }, + } - onKeyUp: function(ev) { - if (this.refs.textarea.scrollHeight !== this.oldScrollHeight || - ev.keyCode === KeyCode.DELETE || - ev.keyCode === KeyCode.BACKSPACE) - { - this.resizeInput(); + startServerTypingTimer() { + if (!this.serverTypingTimer) { + var self = this; + this.serverTypingTimer = setTimeout(function() { + if (self.isTyping) { + self.sendTyping(self.isTyping); + self.startServerTypingTimer(); + } + }, TYPING_SERVER_TIMEOUT / 2); } - }, + } - onEnter: function(ev) { - var contentText = this.refs.textarea.value; - - // bodge for now to set markdown state on/off. We probably want a separate - // area for "local" commands which don't hit out to the server. - if (contentText.indexOf("/markdown") === 0) { - ev.preventDefault(); - this.refs.textarea.value = ''; - if (contentText.indexOf("/markdown on") === 0) { - this.markdownEnabled = true; - } - else if (contentText.indexOf("/markdown off") === 0) { - this.markdownEnabled = false; - } - else { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Unknown command", - description: "Usage: /markdown on|off" - }); - } - return; + stopServerTypingTimer() { + if (this.serverTypingTimer) { + clearTimeout(this.servrTypingTimer); + this.serverTypingTimer = null; } + } + + sendTyping(isTyping) { + MatrixClientPeg.get().sendTyping( + this.props.room.roomId, + this.isTyping, TYPING_SERVER_TIMEOUT + ).done(); + } + + refreshTyping() { + if (this.typingTimeout) { + clearTimeout(this.typingTimeout); + this.typingTimeout = null; + } + } + + onInputClick(ev) { + this.refs.editor.focus(); + } + + onChange(editorState: EditorState) { + this.setState({editorState}); + + if(editorState.getCurrentContent().hasText()) { + this.onTypingActivity() + } else { + this.onFinishedTyping(); + } + } + + enableRichtext(enabled: boolean) { + if (enabled) { + let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); + this.setState({ + editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html)) + }); + } else { + let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), + contentState = ContentState.createFromText(markdown); + this.setState({ + editorState: this.createEditorState(enabled, contentState) + }); + } + + window.localStorage.setItem('mx_editor_rte_enabled', enabled); + + this.setState({ + isRichtextEnabled: enabled + }); + } + + handleKeyCommand(command: string): boolean { + if(command === 'toggle-mode') { + this.enableRichtext(!this.state.isRichtextEnabled); + return true; + } + + let newState: ?EditorState = null; + + // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. + if(!this.state.isRichtextEnabled) { + let contentState = this.state.editorState.getCurrentContent(), + selection = this.state.editorState.getSelection(); + + let modifyFn = { + bold: text => `**${text}**`, + italic: text => `*${text}*`, + underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* + code: text => `\`${text}\`` + }[command]; + + if(modifyFn) { + newState = EditorState.push( + this.state.editorState, + RichText.modifyText(contentState, selection, modifyFn), + 'insert-characters' + ); + } + } + + if(newState == null) + newState = RichUtils.handleKeyCommand(this.state.editorState, command); + + if (newState != null) { + this.onChange(newState); + return true; + } + return false; + } + + handleReturn(ev) { + if(ev.shiftKey) + return false; + + const contentState = this.state.editorState.getCurrentContent(); + if(!contentState.hasText()) + return true; + + let contentText = contentState.getPlainText(), contentHTML; var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); if (cmd) { - ev.preventDefault(); if (!cmd.error) { - this.refs.textarea.value = ''; + this.setState({ + editorState: this.createEditorState() + }); } if (cmd.promise) { cmd.promise.done(function() { @@ -328,121 +451,75 @@ module.exports = React.createClass({ description: cmd.error }); } - return; + return true; } - var isEmote = /^\/me( |$)/i.test(contentText); - var sendMessagePromise; - - if (isEmote) { - contentText = contentText.substring(4); - } - else if (contentText[0] === '/') { - contentText = contentText.substring(1); + if(this.state.isRichtextEnabled) { + contentHTML = RichText.contentStateToHTML(contentState); + } else { + contentHTML = mdownToHtml(contentText); } - var htmlText; - if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) { - sendMessagePromise = isEmote ? - MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) : - MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); - } - else { - sendMessagePromise = isEmote ? - MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : - MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); + let sendFn = this.client.sendHtmlMessage; + + if (contentText.startsWith('/me')) { + contentText = contentText.replace('/me', ''); + // bit of a hack, but the alternative would be quite complicated + contentHTML = contentHTML.replace('/me', ''); + sendFn = this.client.sendHtmlEmote; } - sendMessagePromise.done(function() { + this.sentHistory.push(contentHTML); + let sendMessagePromise = sendFn.call(this.client, this.props.room.roomId, contentText, contentHTML); + + sendMessagePromise.done(() => { dis.dispatch({ action: 'message_sent' }); - }, function() { + }, () => { dis.dispatch({ action: 'message_send_failed' }); }); - this.refs.textarea.value = ''; - this.resizeInput(); - ev.preventDefault(); - }, - onTypingActivity: function() { - this.isTyping = true; - if (!this.userTypingTimer) { - this.sendTyping(true); + this.setState({ + editorState: this.createEditorState() + }); + + return true; + } + + render() { + let className = "mx_MessageComposer_input"; + + if(this.state.isRichtextEnabled) { + className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode } - this.startUserTypingTimer(); - this.startServerTypingTimer(); - }, - onFinishedTyping: function() { - this.isTyping = false; - this.sendTyping(false); - this.stopUserTypingTimer(); - this.stopServerTypingTimer(); - }, - - startUserTypingTimer: function() { - this.stopUserTypingTimer(); - var self = this; - this.userTypingTimer = setTimeout(function() { - self.isTyping = false; - self.sendTyping(self.isTyping); - self.userTypingTimer = null; - }, TYPING_USER_TIMEOUT); - }, - - stopUserTypingTimer: function() { - if (this.userTypingTimer) { - clearTimeout(this.userTypingTimer); - this.userTypingTimer = null; - } - }, - - startServerTypingTimer: function() { - if (!this.serverTypingTimer) { - var self = this; - this.serverTypingTimer = setTimeout(function() { - if (self.isTyping) { - self.sendTyping(self.isTyping); - self.startServerTypingTimer(); - } - }, TYPING_SERVER_TIMEOUT / 2); - } - }, - - stopServerTypingTimer: function() { - if (this.serverTypingTimer) { - clearTimeout(this.servrTypingTimer); - this.serverTypingTimer = null; - } - }, - - sendTyping: function(isTyping) { - MatrixClientPeg.get().sendTyping( - this.props.room.roomId, - this.isTyping, TYPING_SERVER_TIMEOUT - ).done(); - }, - - refreshTyping: function() { - if (this.typingTimeout) { - clearTimeout(this.typingTimeout); - this.typingTimeout = null; - } - }, - - onInputClick: function(ev) { - this.refs.textarea.focus(); - }, - - render: function() { return ( -

-