diff --git a/package.json b/package.json index 85b68f659b..192cefdf3a 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,9 @@ "dependencies": { "browser-request": "^0.3.3", "classnames": "^2.1.2", - "draft-js": "^0.7.0", - "draft-js-export-html": "^0.2.2", + "draft-js": "^0.8.1", + "draft-js-export-html": "^0.4.0", "draft-js-export-markdown": "^0.2.0", - "draft-js-import-markdown": "^0.1.6", "emojione": "2.2.3", "favico.js": "^0.3.10", "filesize": "^3.1.2", diff --git a/src/RichText.js b/src/RichText.js index 7cd78a14c9..31d82ee349 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -14,64 +14,22 @@ import { } from 'draft-js'; import * as sdk from './index'; import * as emojione from 'emojione'; - -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', -}; +import {stateToHTML} from 'draft-js-export-html'; const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, ITALIC: /([\*_])([\w\s]+?)\1/g, BOLD: /([\*_])\1([\w\s]+?)\1\1/g, + HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g, + CODE: /`[^`]*`/g, + STRIKETHROUGH: /~{2}[^~]*~{2}/g, }; const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, '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 const contentStateToHTML = stateToHTML; export function HTMLtoContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); @@ -98,6 +56,19 @@ function unicodeToEmojiUri(str) { return str; } +/** + * 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); + } +} + // Workaround for https://github.com/facebook/draft-js/issues/414 let emojiDecorator = { strategy: (contentBlock, callback) => { @@ -151,7 +122,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { } export function getScopedMDDecorators(scope: any): CompositeDecorator { - let markdownDecorators = ['BOLD', 'ITALIC'].map( + let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( (style) => ({ strategy: (contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); @@ -178,19 +149,6 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator { 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. */ diff --git a/src/Rooms.js b/src/Rooms.js index 7f4564b439..436580371e 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -14,6 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +import MatrixClientPeg from './MatrixClientPeg'; +import DMRoomMap from './utils/DMRoomMap'; + /** * Given a room object, return the alias we should use for it, @@ -75,3 +78,57 @@ export function looksLikeDirectMessageRoom(room, me) { } return false; } + +/** + * Marks or unmarks the given room as being as a DM room. + * @param {string} roomId The ID of the room to modify + * @param {string} userId The user ID of the desired DM + room target user or null to un-mark + this room as a DM room + * @returns {object} A promise + */ +export function setDMRoom(roomId, userId) { + const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); + let dmRoomMap = {}; + + if (mDirectEvent !== undefined) dmRoomMap = mDirectEvent.getContent(); + + for (const thisUserId of Object.keys(dmRoomMap)) { + const roomList = dmRoomMap[thisUserId]; + + if (thisUserId == userId) { + if (roomList.indexOf(roomId) == -1) { + roomList.push(roomId); + } + } else { + const indexOfRoom = roomList.indexOf(roomId); + if (indexOfRoom > -1) { + roomList.splice(indexOfRoom, 1); + } + } + } + + return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); +} + +/** + * Given a room, estimate which of its members is likely to + * be the target if the room were a DM room and return that user. + */ +export function guessDMRoomTarget(room, me) { + let oldestTs; + let oldestUser; + + // Pick the user who's been here longest (and isn't us) + for (const user of room.currentState.getMembers()) { + if (user.userId == me.userId) continue; + + if (oldestTs === undefined || user.events.member.getTs() < oldestTs) { + oldestUser = user; + oldestTs = user.events.member.getTs(); + } + } + + if (oldestUser === undefined) return me; + return oldestUser; +} diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index cc552ba898..952648010c 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -123,6 +123,8 @@ Example: const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require("./MatrixClientPeg"); +const MatrixEvent = require("matrix-js-sdk").MatrixEvent; +const dis = require("./dispatcher"); function sendResponse(event, res) { const data = JSON.parse(JSON.stringify(event.data)); @@ -188,17 +190,52 @@ function setBotOptions(event, roomId, userId) { }); } +function setBotPower(event, roomId, userId, level) { + if (!(Number.isInteger(level) && level >= 0)) { + sendError(event, "Power level must be positive integer."); + return; + } + + console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`); + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, "You need to be logged in."); + return; + } + + client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => { + let powerEvent = new MatrixEvent( + { + type: "m.room.power_levels", + content: powerLevels, + } + ); + + client.setPowerLevel(roomId, userId, level, powerEvent).done(() => { + sendResponse(event, { + success: true, + }); + }, (err) => { + sendError(event, err.message ? err.message : "Failed to send request.", err); + }); + }); +} + function getMembershipState(event, roomId, userId) { console.log(`membership_state of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.member", userId); } +function getJoinRules(event, roomId) { + console.log(`join_rules of ${roomId} requested.`); + returnStateEvent(event, roomId, "m.room.join_rules", ""); +} + function botOptions(event, roomId, userId) { console.log(`bot_options of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); } - function returnStateEvent(event, roomId, eventType, stateKey) { const client = MatrixClientPeg.get(); if (!client) { @@ -218,6 +255,17 @@ function returnStateEvent(event, roomId, eventType, stateKey) { sendResponse(event, stateEvent.getContent()); } +var currentRoomId = null; + +// Listen for when a room is viewed +dis.register(onAction); +function onAction(payload) { + if (payload.action !== "view_room") { + return; + } + currentRoomId = payload.room_id; +} + const onMessage = function(event) { if (!event.origin) { // stupid chrome event.origin = event.originalEvent.origin; @@ -235,14 +283,29 @@ const onMessage = function(event) { const roomId = event.data.room_id; const userId = event.data.user_id; - if (!userId) { - sendError(event, "Missing user_id in request"); - return; - } if (!roomId) { sendError(event, "Missing room_id in request"); return; } + if (!currentRoomId) { + sendError(event, "Must be viewing a room"); + return; + } + if (roomId !== currentRoomId) { + sendError(event, "Room " + roomId + " not visible"); + return; + } + + // Getting join rules does not require userId + if (event.data.action === "join_rules_state") { + getJoinRules(event, roomId); + return; + } + + if (!userId) { + sendError(event, "Missing user_id in request"); + return; + } switch (event.data.action) { case "membership_state": getMembershipState(event, roomId, userId); @@ -256,6 +319,9 @@ const onMessage = function(event) { case "set_bot_options": setBotOptions(event, roomId, userId); break; + case "set_bot_power": + setBotPower(event, roomId, userId, event.data.level); + break; default: console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); break; diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index f4eb4f0d83..3e0c7127c1 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -130,9 +130,9 @@ module.exports = { return event ? event.getContent() : {}; }, - getSyncedSetting: function(type) { + getSyncedSetting: function(type, defaultValue = null) { var settings = this.getSyncedSettings(); - return settings[type]; + return settings.hasOwnProperty(type) ? settings[type] : null; }, setSyncedSetting: function(type, value) { diff --git a/src/component-index.js b/src/component-index.js index 762412e2c2..488b85670b 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -47,6 +47,7 @@ module.exports.components['views.avatars.RoomAvatar'] = require('./components/vi module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton'); module.exports.components['views.create_room.Presets'] = require('./components/views/create_room/Presets'); module.exports.components['views.create_room.RoomAlias'] = require('./components/views/create_room/RoomAlias'); +module.exports.components['views.dialogs.ChatInviteDialog'] = require('./components/views/dialogs/ChatInviteDialog'); module.exports.components['views.dialogs.DeactivateAccountDialog'] = require('./components/views/dialogs/DeactivateAccountDialog'); module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); @@ -55,6 +56,7 @@ module.exports.components['views.dialogs.NeedToRegisterDialog'] = require('./com module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog'); module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog'); +module.exports.components['views.elements.AddressTile'] = require('./components/views/elements/AddressTile'); module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText'); module.exports.components['views.elements.EditableTextContainer'] = require('./components/views/elements/EditableTextContainer'); module.exports.components['views.elements.EmojiText'] = require('./components/views/elements/EmojiText'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 251f3f1dc8..c83da2b8f0 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -370,6 +370,9 @@ module.exports = React.createClass({ this._setPage(this.PageTypes.RoomDirectory); this.notifyNewScreen('directory'); break; + case 'view_create_chat': + this._createChat(); + break; case 'notifier_enabled': this.forceUpdate(); break; @@ -506,6 +509,13 @@ module.exports = React.createClass({ } }, + _createChat: function() { + var ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); + Modal.createDialog(ChatInviteDialog, { + title: "Start a one to one chat", + }); + }, + // update scrollStateMap according to the current scroll state of the // room view. _updateScrollMap: function() { diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js new file mode 100644 index 0000000000..e322127135 --- /dev/null +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -0,0 +1,371 @@ +/* +Copyright 2015, 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 classNames = require('classnames'); +var sdk = require("../../../index"); +var Invite = require("../../../Invite"); +var createRoom = require("../../../createRoom"); +var MatrixClientPeg = require("../../../MatrixClientPeg"); +var DMRoomMap = require('../../../utils/DMRoomMap'); +var rate_limited_func = require("../../../ratelimitedfunc"); +var dis = require("../../../dispatcher"); +var Modal = require('../../../Modal'); + +const TRUNCATE_QUERY_LIST = 40; + +module.exports = React.createClass({ + displayName: "ChatInviteDialog", + propTypes: { + title: React.PropTypes.string, + description: React.PropTypes.oneOfType([ + React.PropTypes.element, + React.PropTypes.string, + ]), + value: React.PropTypes.string, + placeholder: React.PropTypes.string, + button: React.PropTypes.string, + focus: React.PropTypes.bool, + onFinished: React.PropTypes.func.isRequired + }, + + getDefaultProps: function() { + return { + title: "Start a chat", + description: "Who would you like to communicate with?", + value: "", + placeholder: "User ID, Name or email", + button: "Start Chat", + focus: true + }; + }, + + getInitialState: function() { + return { + user: null, + queryList: [], + addressSelected: false, + selected: 0, + hover: false, + }; + }, + + componentDidMount: function() { + if (this.props.focus) { + // Set the cursor at the end of the text input + this.refs.textinput.value = this.props.value; + } + this._updateUserList(); + }, + + componentDidUpdate: function() { + // As the user scrolls with the arrow keys keep the selected item + // at the top of the window. + if (this.scrollElement && !this.state.hover) { + var elementHeight = this.queryListElement.getBoundingClientRect().height; + this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; + } + }, + + onStartChat: function() { + var addr; + + // Either an address tile was created, or text input is being used + if (this.state.user) { + addr = this.state.user.userId; + } else { + addr = this.refs.textinput.value; + } + + // Check if the addr is a valid type + if (Invite.getAddressType(addr) === "mx") { + var room = this._getDirectMessageRoom(addr); + if (room) { + // A Direct Message room already exists for this user and you + // so go straight to that room + dis.dispatch({ + action: 'view_room', + room_id: room.roomId, + }); + this.props.onFinished(true, addr); + } else { + this._startChat(addr); + } + } else if (Invite.getAddressType(addr) === "email") { + this._startChat(addr); + } else { + // Nothing to do, so focus back on the textinput + this.refs.textinput.focus(); + } + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + onKeyDown: function(e) { + if (e.keyCode === 27) { // escape + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(false); + } else if (e.keyCode === 38) { // up arrow + e.stopPropagation(); + e.preventDefault(); + if (this.state.selected > 0) { + this.setState({ + selected: this.state.selected - 1, + hover : false, + }); + } + } else if (e.keyCode === 40) { // down arrow + e.stopPropagation(); + e.preventDefault(); + if (this.state.selected < this._maxSelected(this.state.queryList)) { + this.setState({ + selected: this.state.selected + 1, + hover : false, + }); + } + } else if (e.keyCode === 13) { // enter + e.stopPropagation(); + e.preventDefault(); + if (this.state.queryList.length > 0) { + this.setState({ + user: this.state.queryList[this.state.selected], + addressSelected: true, + queryList: [], + hover : false, + }); + } + } + }, + + onQueryChanged: function(ev) { + var query = ev.target.value; + var queryList = []; + + // Only do search if there is something to search + if (query.length > 0) { + queryList = this._userList.filter((user) => { + return this._matches(query, user); + }); + } + + // Make sure the selected item isn't outside the list bounds + var selected = this.state.selected; + var maxSelected = this._maxSelected(queryList); + if (selected > maxSelected) { + selected = maxSelected; + } + + this.setState({ + queryList: queryList, + selected: selected, + }); + }, + + onDismissed: function() { + this.setState({ + user: null, + addressSelected: false, + selected: 0, + queryList: [], + }); + }, + + onClick: function(index) { + var self = this; + return function() { + self.setState({ + user: self.state.queryList[index], + addressSelected: true, + queryList: [], + hover: false, + }); + }; + }, + + onMouseEnter: function(index) { + var self = this; + return function() { + self.setState({ + selected: index, + hover: true, + }); + }; + }, + + onMouseLeave: function() { + this.setState({ hover : false }); + }, + + createQueryListTiles: function() { + var self = this; + var TintableSvg = sdk.getComponent("elements.TintableSvg"); + var AddressTile = sdk.getComponent("elements.AddressTile"); + var maxSelected = this._maxSelected(this.state.queryList); + var queryList = []; + + // Only create the query elements if there are queries + if (this.state.queryList.length > 0) { + for (var i = 0; i <= maxSelected; i++) { + var classes = classNames({ + "mx_ChatInviteDialog_queryListElement": true, + "mx_ChatInviteDialog_selected": this.state.selected === i, + }); + + // NOTE: Defaulting to "vector" as the network, until the network backend stuff is done. + // Saving the queryListElement so we can use it to work out, in the componentDidUpdate + // method, how far to scroll when using the arrow keys + queryList.push( +
{ this.queryListElement = ref; }} > + +
+ ); + } + } + return queryList; + }, + + _getDirectMessageRoom: function(addr) { + const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); + var dmRooms = dmRoomMap.getDMRoomsForUserId(addr); + if (dmRooms.length > 0) { + // Cycle through all the DM rooms and find the first non forgotten or parted room + for (let i = 0; i < dmRooms.length; i++) { + let room = MatrixClientPeg.get().getRoom(dmRooms[i]); + if (room) { + return room; + } + } + } + return null; + }, + + _startChat: function(addr) { + // Start the chat + createRoom().then(function(roomId) { + return Invite.inviteToRoom(roomId, addr); + }) + .catch(function(err) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failure to invite user", + description: err.toString() + }); + return null; + }) + .done(); + + // Close - this will happen before the above, as that is async + this.props.onFinished(true, addr); + }, + + _updateUserList: new rate_limited_func(function() { + // Get all the users + this._userList = MatrixClientPeg.get().getUsers(); + }, 500), + + _maxSelected: function(list) { + var listSize = list.length === 0 ? 0 : list.length - 1; + var maxSelected = listSize > (TRUNCATE_QUERY_LIST - 1) ? (TRUNCATE_QUERY_LIST - 1) : listSize + return maxSelected; + }, + + // This is the search algorithm for matching users + _matches: function(query, user) { + var name = user.displayName.toLowerCase(); + var uid = user.userId.toLowerCase(); + query = query.toLowerCase(); + + // direct prefix matches + if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) { + return true; + } + + // strip @ on uid and try matching again + if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) { + return true; + } + + // split spaces in name and try matching constituent parts + var parts = name.split(" "); + for (var i = 0; i < parts.length; i++) { + if (parts[i].indexOf(query) === 0) { + return true; + } + } + return false; + }, + + render: function() { + var TintableSvg = sdk.getComponent("elements.TintableSvg"); + this.scrollElement = null; + + var query; + if (this.state.addressSelected) { + var AddressTile = sdk.getComponent("elements.AddressTile"); + query = ( + + ); + } else { + query = ( + + ); + } + + var queryList; + var queryListElements = this.createQueryListTiles(); + if (queryListElements.length > 0) { + queryList = ( +
{this.scrollElement = ref}}> + { queryListElements } +
+ ); + } + + return ( +
+
+ {this.props.title} +
+
+ +
+
+ +
+
+
{ query }
+ { queryList } +
+
+ +
+
+ ); + } +}); diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js new file mode 100644 index 0000000000..e0a5dbbc80 --- /dev/null +++ b/src/components/views/elements/AddressTile.js @@ -0,0 +1,93 @@ +/* +Copyright 2015, 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. +*/ + +'use strict'; + +var React = require('react'); +var classNames = require('classnames'); +var sdk = require("../../../index"); +var Avatar = require('../../../Avatar'); + +module.exports = React.createClass({ + displayName: 'AddressTile', + + propTypes: { + user: React.PropTypes.object.isRequired, + canDismiss: React.PropTypes.bool, + onDismissed: React.PropTypes.func, + justified: React.PropTypes.bool, + networkName: React.PropTypes.string, + networkUrl: React.PropTypes.string, + }, + + getDefaultProps: function() { + return { + canDismiss: false, + onDismissed: function() {}, // NOP + justified: false, + networkName: "", + networkUrl: "", + }; + }, + + render: function() { + var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + var TintableSvg = sdk.getComponent("elements.TintableSvg"); + var userId = this.props.user.userId; + var name = this.props.user.displayName || userId; + var imgUrl = Avatar.avatarUrlForUser(this.props.user, 25, 25, "crop"); + + var network; + if (this.props.networkUrl !== "") { + network = ( +
+ +
+ ); + } + + var dismiss; + if (this.props.canDismiss) { + dismiss = ( +
+ +
+ ); + } + + var nameClasses = classNames({ + "mx_AddressTile_name": true, + "mx_AddressTile_justified": this.props.justified, + }); + + var idClasses = classNames({ + "mx_AddressTile_id": true, + "mx_AddressTile_justified": this.props.justified, + }); + + return ( +
+ { network } +
+ +
+
{ name }
+
{ userId }
+ { dismiss } +
+ ); + } +}); diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 4b2e23a8b8..9b8b55ab51 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -149,13 +149,13 @@ export default class Autocomplete extends React.Component { {completionResult.provider.renderCompletions(completions)}
) : null; - }); + }).filter(completion => !!completion); - return ( + return renderedCompletions.length > 0 ? (
this.container = e}> {renderedCompletions}
- ); + ) : null; } } diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index d5e7bf3abd..4eb7801e13 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -21,6 +21,7 @@ var Modal = require('../../../Modal'); var sdk = require('../../../index'); var dis = require('../../../dispatcher'); import Autocomplete from './Autocomplete'; +import classNames from 'classnames'; import UserSettingsStore from '../../../UserSettingsStore'; @@ -38,10 +39,20 @@ export default class MessageComposer extends React.Component { this.onDownArrow = this.onDownArrow.bind(this); this._tryComplete = this._tryComplete.bind(this); 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.state = { autocompleteQuery: '', selection: null, + inputState: { + style: [], + blockType: null, + isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true), + wordCount: 0, + }, + showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false), }; } @@ -134,6 +145,10 @@ export default class MessageComposer extends React.Component { }); } + onInputStateChanged(inputState) { + this.setState({inputState}); + } + onUpArrow() { return this.refs.autocomplete.onUpArrow(); } @@ -155,6 +170,21 @@ export default class MessageComposer extends React.Component { } } + onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", event) { + event.preventDefault(); + this.messageComposerInput.onFormatButtonClicked(name, event); + } + + onToggleFormattingClicked() { + UserSettingsStore.setSyncedSetting('MessageComposer.showFormatting', !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); + } + render() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; @@ -207,6 +237,16 @@ export default class MessageComposer extends React.Component { ); + const formattingButton = ( + + ); + controls.push( this.messageComposerInput = c} @@ -217,7 +257,9 @@ export default class MessageComposer extends React.Component { onUpArrow={this.onUpArrow} onDownArrow={this.onDownArrow} tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete - onContentChanged={this.onInputContentChanged} />, + onContentChanged={this.onInputContentChanged} + onInputStateChanged={this.onInputStateChanged} />, + formattingButton, uploadButton, hangupButton, callButton, @@ -242,6 +284,26 @@ export default class MessageComposer extends React.Component { ; } + + const {style, blockType} = this.state.inputState; + const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map( + name => { + const active = style.includes(name) || blockType === name; + const suffix = active ? '-o-n' : ''; + const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); + const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name; + const className = classNames("mx_MessageComposer_format_button", { + mx_MessageComposer_format_button_disabled: disabled, + }); + return ; + }, + ); + return (
{autoComplete} @@ -250,6 +312,22 @@ export default class MessageComposer extends React.Component { {controls}
+ {UserSettingsStore.isFeatureEnabled('rich_text_editor') ? +
+
+ {formatButtons} +
+ + +
+
: null + } ); } diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 2d42b65246..1f5b303fe0 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -29,9 +29,11 @@ marked.setOptions({ import {Editor, EditorState, RichUtils, CompositeDecorator, convertFromRaw, convertToRaw, Modifier, EditorChangeType, - getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; + getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; import {stateToMarkdown} from 'draft-js-export-markdown'; +import classNames from 'classnames'; +import escape from 'lodash/escape'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; @@ -41,6 +43,7 @@ import sdk from '../../../index'; import dis from '../../../dispatcher'; import KeyCode from '../../../KeyCode'; +import UserSettingsStore from '../../../UserSettingsStore'; import * as RichText from '../../../RichText'; @@ -80,7 +83,6 @@ 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.setEditorState = this.setEditorState.bind(this); @@ -88,15 +90,12 @@ export default class MessageComposerInput extends React.Component { this.onDownArrow = this.onDownArrow.bind(this); this.onTab = this.onTab.bind(this); this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); + this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); - let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); - if (isRichtextEnabled == null) { - isRichtextEnabled = 'true'; - } - isRichtextEnabled = isRichtextEnabled === 'true'; + const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); this.state = { - isRichtextEnabled: isRichtextEnabled, + isRichtextEnabled, editorState: null, }; @@ -236,8 +235,18 @@ export default class MessageComposerInput extends React.Component { this.sentHistory.saveLastTextEntry(); } + componentWillUpdate(nextProps, nextState) { + // this is dirty, but moving all this state to MessageComposer is dirtier + if (this.props.onInputStateChanged && nextState !== this.state) { + const state = this.getSelectionInfo(nextState.editorState); + state.isRichtextEnabled = nextState.isRichtextEnabled; + this.props.onInputStateChanged(state); + } + } + onAction(payload) { let editor = this.refs.editor; + let contentState = this.state.editorState.getCurrentContent(); switch (payload.action) { case 'focus_composer': @@ -246,35 +255,44 @@ export default class MessageComposerInput extends React.Component { // 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'), - }); + case 'insert_displayname': { + contentState = Modifier.replaceText( + contentState, + this.state.editorState.getSelection(), + `${payload.displayname}: ` + ); + let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); + this.setEditorState(editorState); + editor.focus(); + } + break; + + case 'quote': { + let {event: {content: {body, formatted_body}}} = payload.event || {}; + formatted_body = formatted_body || escape(body); + if (formatted_body) { + let content = RichText.HTMLtoContentState(`
${formatted_body}
`); + if (!this.state.isRichtextEnabled) { + content = ContentState.createFromText(stateToMarkdown(content)); + } + + const blockMap = content.getBlockMap(); + let startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); + contentState = Modifier.splitBlock(contentState, startSelection); + startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); + contentState = Modifier.replaceWithFragment(contentState, + startSelection, + blockMap); + startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); + if (this.state.isRichtextEnabled) + contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); + let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + this.setEditorState(editorState); editor.focus(); } - break; - } - } - - 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; - // set a callback so we can see if the cursor position changes as - // a result of this event. If it doesn't, we cycle history. - setTimeout(() => { - if (this.refs.textarea.selectionStart == oldSelectionStart) { - this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); - } - }, 0); + } + break; } } @@ -344,13 +362,10 @@ export default class MessageComposerInput extends React.Component { } } - onInputClick(ev) { - this.refs.editor.focus(); - } - setEditorState(editorState: EditorState) { + setEditorState(editorState: EditorState, cb = () => null) { editorState = RichText.attachImmutableEntitiesToEmoji(editorState); - this.setState({editorState}); + this.setState({editorState}, cb); if (editorState.getCurrentContent().hasText()) { this.onTypingActivity(); @@ -359,27 +374,34 @@ export default class MessageComposerInput extends React.Component { } if (this.props.onContentChanged) { - this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), - RichText.selectionStateToTextOffsets(editorState.getSelection(), - editorState.getCurrentContent().getBlocksAsArray())); + const textContent = editorState.getCurrentContent().getPlainText(); + const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), + editorState.getCurrentContent().getBlocksAsArray()); + + this.props.onContentChanged(textContent, selection); } } enableRichtext(enabled: boolean) { + let contentState = null; if (enabled) { - let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); - this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html))); + const html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); + contentState = RichText.HTMLtoContentState(html); } else { - let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), - contentState = ContentState.createFromText(markdown); - this.setEditorState(this.createEditorState(enabled, contentState)); + let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); + if (markdown[markdown.length - 1] === '\n') { + markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) + } + contentState = ContentState.createFromText(markdown); } - window.localStorage.setItem('mx_editor_rte_enabled', enabled); - - this.setState({ - isRichtextEnabled: enabled + this.setEditorState(this.createEditorState(enabled, contentState), () => { + this.setState({ + isRichtextEnabled: enabled, + }); }); + + UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); } handleKeyCommand(command: string): boolean { @@ -391,7 +413,17 @@ export default class MessageComposerInput extends React.Component { 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) { + if (this.state.isRichtextEnabled) { + // These are block types, not handled by RichUtils by default. + const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; + + if (blockCommands.includes(command)) { + this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command)); + } else if (command === 'strike') { + // this is the only inline style not handled by Draft by default + this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH')); + } + } else { let contentState = this.state.editorState.getCurrentContent(), selection = this.state.editorState.getSelection(); @@ -399,7 +431,11 @@ export default class MessageComposerInput extends React.Component { bold: text => `**${text}**`, italic: text => `*${text}*`, underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* + strike: text => `~~${text}~~`, code: text => `\`${text}\``, + blockquote: text => text.split('\n').map(line => `> ${line}\n`).join(''), + 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), + 'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''), }[command]; if (modifyFn) { @@ -418,12 +454,14 @@ export default class MessageComposerInput extends React.Component { this.setEditorState(newState); return true; } + return false; } handleReturn(ev) { if (ev.shiftKey) { - return false; + this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState)); + return true; } const contentState = this.state.editorState.getCurrentContent(); @@ -464,7 +502,7 @@ export default class MessageComposerInput extends React.Component { return true; } - if(this.state.isRichtextEnabled) { + if (this.state.isRichtextEnabled) { contentHTML = RichText.contentStateToHTML(contentState); } else { contentHTML = mdownToHtml(contentText); @@ -536,20 +574,91 @@ export default class MessageComposerInput extends React.Component { setTimeout(() => this.refs.editor.focus(), 50); } - render() { - let className = "mx_MessageComposer_input"; + onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) { + e.preventDefault(); // don't steal focus from the editor! + const command = { + code: 'code-block', + quote: 'blockquote', + bullet: 'unordered-list-item', + numbullet: 'ordered-list-item', + }[name] || name; + this.handleKeyCommand(command); + } - if (this.state.isRichtextEnabled) { - className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode + /* returns inline style and block type of current SelectionState so MessageComposer can render formatting + buttons. */ + getSelectionInfo(editorState: EditorState) { + const styleName = { + BOLD: 'bold', + ITALIC: 'italic', + STRIKETHROUGH: 'strike', + UNDERLINE: 'underline', + }; + + const originalStyle = editorState.getCurrentInlineStyle().toArray(); + const style = originalStyle + .map(style => styleName[style] || null) + .filter(styleName => !!styleName); + + const blockName = { + 'code-block': 'code', + blockquote: 'quote', + 'unordered-list-item': 'bullet', + 'ordered-list-item': 'numbullet', + }; + const originalBlockType = editorState.getCurrentContent() + .getBlockForKey(editorState.getSelection().getStartKey()) + .getType(); + const blockType = blockName[originalBlockType] || null; + + return { + style, + blockType, + }; + } + + onMarkdownToggleClicked(e) { + e.preventDefault(); // don't steal focus from the editor! + this.handleKeyCommand('toggle-mode'); + } + + getBlockStyle(block: ContentBlock): ?string { + if (block.getType() === 'strikethrough') { + return 'mx_Markdown_STRIKETHROUGH'; } + return null; + } + + render() { + const {editorState} = this.state; + + // From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92 + // If the user changes block type before entering any text, we can + // either style the placeholder or hide it. + let hidePlaceholder = false; + const contentState = editorState.getCurrentContent(); + if (!contentState.hasText()) { + if (contentState.getBlockMap().first().getType() !== 'unstyled') { + hidePlaceholder = true; + } + } + + const className = classNames('mx_MessageComposer_input', { + mx_MessageComposer_input_empty: hidePlaceholder, + }); + return ( -
+
+ {name}; } - } - else if (this.state.hover) { + } else if (this.state.hover) { var RoomTooltip = sdk.getComponent("rooms.RoomTooltip"); - label = ; + tooltip = ; } var incomingCallBox;