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 => `${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('')}${elem}>`;
-
- // 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}
+
+
+
+
+
+ { this.props.description }
+
+
+
{ query }
+ { queryList }
+
+
+
+ {this.props.button}
+
+
+
+ );
+ }
+});
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;