Merge remote-tracking branch 'origin/develop' into erikj/group_server

This commit is contained in:
David Baker 2017-06-26 17:49:06 +01:00
commit 09b1012388
21 changed files with 654 additions and 285 deletions

6
.flowconfig Normal file
View file

@ -0,0 +1,6 @@
[include]
src/**/*.js
test/**/*.js
[ignore]
node_modules/

View file

@ -51,7 +51,7 @@
"classnames": "^2.1.2", "classnames": "^2.1.2",
"commonmark": "^0.27.0", "commonmark": "^0.27.0",
"counterpart": "^0.18.0", "counterpart": "^0.18.0",
"draft-js": "^0.8.1", "draft-js": "^0.9.1",
"draft-js-export-html": "^0.5.0", "draft-js-export-html": "^0.5.0",
"draft-js-export-markdown": "^0.2.0", "draft-js-export-markdown": "^0.2.0",
"emojione": "2.2.3", "emojione": "2.2.3",
@ -64,7 +64,7 @@
"isomorphic-fetch": "^2.2.1", "isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3", "linkifyjs": "^2.1.3",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"matrix-js-sdk": "0.7.13", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"q": "^1.4.1", "q": "^1.4.1",

View file

@ -0,0 +1,84 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ContentState} from 'draft-js';
import * as RichText from './RichText';
import Markdown from './Markdown';
import _flow from 'lodash/flow';
import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown';
class HistoryItem {
message: string = '';
format: MessageFormat = 'html';
constructor(message: string, format: MessageFormat) {
this.message = message;
this.format = format;
}
toContentState(format: MessageFormat): ContentState {
let {message} = this;
if (format === 'markdown') {
if (this.format === 'html') {
message = _flow([RichText.htmlToContentState, RichText.stateToMarkdown])(message);
}
return ContentState.createFromText(message);
} else {
if (this.format === 'markdown') {
message = new Markdown(message).toHTML();
}
return RichText.htmlToContentState(message);
}
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0;
currentIndex: number = -1;
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
for(; sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`); this.lastIndex++, this.currentIndex++) {
this.history.push(
Object.assign(
new HistoryItem(),
JSON.parse(sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`)),
),
);
}
this.currentIndex--;
}
addItem(message: string, format: MessageFormat) {
const item = new HistoryItem(message, format);
this.history.push(item);
this.currentIndex = this.lastIndex + 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item));
}
getItem(offset: number, format: MessageFormat): ?ContentState {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1);
const item = this.history[this.currentIndex];
return item ? item.toContentState(format) : null;
}
}

View file

@ -16,6 +16,7 @@ import * as sdk from './index';
import * as emojione from 'emojione'; import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html'; import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter"; import {SelectionRange} from "./autocomplete/Autocompleter";
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
const MARKDOWN_REGEX = { const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
@ -30,9 +31,26 @@ const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export const contentStateToHTML = stateToHTML; const ZWS_CODE = 8203;
const ZWS = String.fromCharCode(ZWS_CODE); // zero width space
export function stateToMarkdown(state) {
return __stateToMarkdown(state)
.replace(
ZWS, // draft-js-export-markdown adds these
''); // this is *not* a zero width space, trust me :)
}
export function HTMLtoContentState(html: string): ContentState { export const contentStateToHTML = (contentState: ContentState) => {
return stateToHTML(contentState, {
inlineStyles: {
UNDERLINE: {
element: 'u'
}
}
});
};
export function htmlToContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html)); return ContentState.createFromBlockArray(convertFromHTML(html));
} }
@ -146,9 +164,9 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
</a> </a>
) )
}); });
markdownDecorators.push(emojiDecorator); // markdownDecorators.push(emojiDecorator);
// TODO Consider renabling "syntax highlighting" when we can do it properly
return markdownDecorators; return [emojiDecorator];
} }
/** /**

View file

@ -37,7 +37,26 @@ module.exports = {
}, },
doesRoomHaveUnreadMessages: function(room) { doesRoomHaveUnreadMessages: function(room) {
var readUpToId = room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); var myUserId = MatrixClientPeg.get().credentials.userId;
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
// despite the name of the method :((
var readUpToId = room.getEventReadUpTo(myUserId);
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/riot-web/issues/3263
// https://github.com/vector-im/riot-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/riot-web/issues/3363
if (room.timeline.length &&
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId)
{
return false;
}
// this just looks at whatever history we have, which if we've only just started // this just looks at whatever history we have, which if we've only just started
// up probably won't be very much, so if the last couple of events are ones that // up probably won't be very much, so if the last couple of events are ones that
// don't count, we don't know if there are any events that do count between where // don't count, we don't know if there are any events that do count between where

View file

@ -19,7 +19,7 @@ import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter'; import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) { constructor(commandRegex?: RegExp) {
if (commandRegex) { if (commandRegex) {
if (!commandRegex.global) { if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set'); throw new Error('commandRegex must have global flag set');

View file

@ -59,7 +59,7 @@ export async function getCompletions(query: string, selection: SelectionRange, f
PROVIDERS.map(provider => { PROVIDERS.map(provider => {
return Q(provider.getCompletions(query, selection, force)) return Q(provider.getCompletions(query, selection, force))
.timeout(PROVIDER_COMPLETION_TIMEOUT); .timeout(PROVIDER_COMPLETION_TIMEOUT);
}) }),
); );
return completionsList return completionsList

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Fuse from 'fuse.js'; import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file // Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file
@ -28,11 +28,21 @@ const COMMANDS = [
args: '<message>', args: '<message>',
description: 'Displays action', description: 'Displays action',
}, },
{
command: '/part',
args: '[#alias:domain]',
description: 'Leave room',
},
{ {
command: '/ban', command: '/ban',
args: '<user-id> [reason]', args: '<user-id> [reason]',
description: 'Bans user with given id', description: 'Bans user with given id',
}, },
{
command: '/unban',
args: '<user-id>',
description: 'Unbans user with given id',
},
{ {
command: '/deop', command: '/deop',
args: '<user-id>', args: '<user-id>',
@ -63,6 +73,11 @@ const COMMANDS = [
args: '<query>', args: '<query>',
description: 'Searches DuckDuckGo for results', description: 'Searches DuckDuckGo for results',
}, },
{
command: '/op',
args: '<userId> [<power level>]',
description: 'Define the power level of a user',
},
]; ];
const COMMAND_RE = /(^\/\w*)/g; const COMMAND_RE = /(^\/\w*)/g;
@ -72,7 +87,7 @@ let instance = null;
export default class CommandProvider extends AutocompleteProvider { export default class CommandProvider extends AutocompleteProvider {
constructor() { constructor() {
super(COMMAND_RE); super(COMMAND_RE);
this.fuse = new Fuse(COMMANDS, { this.matcher = new FuzzyMatcher(COMMANDS, {
keys: ['command', 'args', 'description'], keys: ['command', 'args', 'description'],
}); });
} }
@ -81,7 +96,7 @@ export default class CommandProvider extends AutocompleteProvider {
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
completions = this.fuse.search(command[0]).map((result) => { completions = this.matcher.match(command[0]).map((result) => {
return { return {
completion: result.command + ' ', completion: result.command + ' ',
component: (<TextualCompletion component: (<TextualCompletion

View file

@ -19,20 +19,26 @@ import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js'; import FuzzyMatcher from './FuzzyMatcher';
import sdk from '../index'; import sdk from '../index';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter'; import type {SelectionRange, Completion} from './Autocompleter';
const EMOJI_REGEX = /:\w*:?/g; const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_SHORTNAMES = Object.keys(emojioneList).map(shortname => {
return {
shortname,
};
});
let instance = null; let instance = null;
export default class EmojiProvider extends AutocompleteProvider { export default class EmojiProvider extends AutocompleteProvider {
constructor() { constructor() {
super(EMOJI_REGEX); super(EMOJI_REGEX);
this.fuse = new Fuse(EMOJI_SHORTNAMES, {}); this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: 'shortname',
});
} }
async getCompletions(query: string, selection: SelectionRange) { async getCompletions(query: string, selection: SelectionRange) {
@ -41,8 +47,8 @@ export default class EmojiProvider extends AutocompleteProvider {
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
completions = this.fuse.search(command[0]).map(result => { completions = this.matcher.match(command[0]).map(result => {
const shortname = EMOJI_SHORTNAMES[result]; const {shortname} = result;
const unicode = shortnameToUnicode(shortname); const unicode = shortnameToUnicode(shortname);
return { return {
completion: unicode, completion: unicode,

View file

@ -0,0 +1,107 @@
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
//import Levenshtein from 'liblevenshtein';
//import _at from 'lodash/at';
//import _flatMap from 'lodash/flatMap';
//import _sortBy from 'lodash/sortBy';
//import _sortedUniq from 'lodash/sortedUniq';
//import _keys from 'lodash/keys';
//
//class KeyMap {
// keys: Array<String>;
// objectMap: {[String]: Array<Object>};
// priorityMap: {[String]: number}
//}
//
//const DEFAULT_RESULT_COUNT = 10;
//const DEFAULT_DISTANCE = 5;
// FIXME Until Fuzzy matching works better, we use prefix matching.
import PrefixMatcher from './QueryMatcher';
export default PrefixMatcher;
//class FuzzyMatcher { // eslint-disable-line no-unused-vars
// /**
// * @param {object[]} objects the objects to perform a match on
// * @param {string[]} keys an array of keys within each object to match on
// * Keys can refer to object properties by name and as in JavaScript (for nested properties)
// *
// * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the
// * resulting KeyMap.
// *
// * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
// * @return {KeyMap}
// */
// static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
// const keyMap = new KeyMap();
// const map = {};
// const priorities = {};
//
// objects.forEach((object, i) => {
// const keyValues = _at(object, keys);
// console.log(object, keyValues, keys);
// for (const keyValue of keyValues) {
// if (!map.hasOwnProperty(keyValue)) {
// map[keyValue] = [];
// }
// map[keyValue].push(object);
// }
// priorities[object] = i;
// });
//
// keyMap.objectMap = map;
// keyMap.priorityMap = priorities;
// keyMap.keys = _sortBy(_keys(map), [(value) => priorities[value]]);
// return keyMap;
// }
//
// constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
// this.options = options;
// this.keys = options.keys;
// this.setObjects(objects);
// }
//
// setObjects(objects: Array<Object>) {
// this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys);
// console.log(this.keyMap.keys);
// this.matcher = new Levenshtein.Builder()
// .dictionary(this.keyMap.keys, true)
// .algorithm('transposition')
// .sort_candidates(false)
// .case_insensitive_sort(true)
// .include_distance(true)
// .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense
// .build();
// }
//
// match(query: String): Array<Object> {
// const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE);
// // TODO FIXME This is hideous. Clean up when possible.
// const val = _sortedUniq(_sortBy(_flatMap(candidates, (candidate) => {
// return this.keyMap.objectMap[candidate[0]].map((value) => {
// return {
// distance: candidate[1],
// ...value,
// };
// });
// }),
// [(candidate) => candidate.distance, (candidate) => this.keyMap.priorityMap[candidate]]));
// console.log(val);
// return val;
// }
//}

View file

@ -0,0 +1,79 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import _at from 'lodash/at';
import _flatMap from 'lodash/flatMap';
import _sortBy from 'lodash/sortBy';
import _sortedUniq from 'lodash/sortedUniq';
import _keys from 'lodash/keys';
class KeyMap {
keys: Array<String>;
objectMap: {[String]: Array<Object>};
priorityMap = new Map();
}
export default class QueryMatcher {
/**
* @param {object[]} objects the objects to perform a match on
* @param {string[]} keys an array of keys within each object to match on
* Keys can refer to object properties by name and as in JavaScript (for nested properties)
*
* To use, simply presort objects by required criteria, run through this function and create a QueryMatcher with the
* resulting KeyMap.
*
* TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
* @return {KeyMap}
*/
static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
const keyMap = new KeyMap();
const map = {};
objects.forEach((object, i) => {
const keyValues = _at(object, keys);
for (const keyValue of keyValues) {
if (!map.hasOwnProperty(keyValue)) {
map[keyValue] = [];
}
map[keyValue].push(object);
}
keyMap.priorityMap.set(object, i);
});
keyMap.objectMap = map;
keyMap.keys = _keys(map);
return keyMap;
}
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
this.options = options;
this.keys = options.keys;
this.setObjects(objects);
}
setObjects(objects: Array<Object>) {
this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys);
}
match(query: String): Array<Object> {
query = query.toLowerCase().replace(/[^\w]/g, '');
const results = _sortedUniq(_sortBy(_flatMap(this.keyMap.keys, (key) => {
return key.toLowerCase().replace(/[^\w]/g, '').indexOf(query) >= 0 ? this.keyMap.objectMap[key] : [];
}), (candidate) => this.keyMap.priorityMap.get(candidate)));
return results;
}
}

View file

@ -19,7 +19,7 @@ import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js'; import FuzzyMatcher from './FuzzyMatcher';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms'; import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index'; import sdk from '../index';
@ -30,11 +30,9 @@ let instance = null;
export default class RoomProvider extends AutocompleteProvider { export default class RoomProvider extends AutocompleteProvider {
constructor() { constructor() {
super(ROOM_REGEX, { super(ROOM_REGEX);
keys: ['displayName', 'userId'], this.matcher = new FuzzyMatcher([], {
}); keys: ['name', 'roomId', 'aliases'],
this.fuse = new Fuse([], {
keys: ['name', 'roomId', 'aliases'],
}); });
} }
@ -46,17 +44,17 @@ export default class RoomProvider extends AutocompleteProvider {
const {command, range} = this.getCurrentCommand(query, selection, force); const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) { if (command) {
// the only reason we need to do this is because Fuse only matches on properties // the only reason we need to do this is because Fuse only matches on properties
this.fuse.set(client.getRooms().filter(room => !!room).map(room => { this.matcher.setObjects(client.getRooms().filter(room => !!room && !!getDisplayAliasForRoom(room)).map(room => {
return { return {
room: room, room: room,
name: room.name, name: room.name,
aliases: room.getAliases(), aliases: room.getAliases(),
}; };
})); }));
completions = this.fuse.search(command[0]).map(room => { completions = this.matcher.match(command[0]).map(room => {
let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return { return {
completion: displayAlias, completion: displayAlias + ' ',
component: ( component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} /> <PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
), ),
@ -84,8 +82,4 @@ export default class RoomProvider extends AutocompleteProvider {
{completions} {completions}
</div>; </div>;
} }
shouldForceComplete(): boolean {
return true;
}
} }

View file

@ -1,3 +1,4 @@
//@flow
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
@ -18,21 +19,27 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Fuse from 'fuse.js';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import sdk from '../index'; import sdk from '../index';
import FuzzyMatcher from './FuzzyMatcher';
import _pull from 'lodash/pull';
import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk';
const USER_REGEX = /@\S*/g; const USER_REGEX = /@\S*/g;
let instance = null; let instance = null;
export default class UserProvider extends AutocompleteProvider { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = [];
constructor() { constructor() {
super(USER_REGEX, { super(USER_REGEX, {
keys: ['name', 'userId'], keys: ['name', 'userId'],
}); });
this.users = []; this.matcher = new FuzzyMatcher([], {
this.fuse = new Fuse([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
}); });
} }
@ -43,8 +50,7 @@ export default class UserProvider extends AutocompleteProvider {
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection, force); let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) { if (command) {
this.fuse.set(this.users); completions = this.matcher.match(command[0]).map(user => {
completions = this.fuse.search(command[0]).map(user => {
let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let completion = displayName; let completion = displayName;
if (range.start === 0) { if (range.start === 0) {
@ -71,8 +77,31 @@ export default class UserProvider extends AutocompleteProvider {
return '👥 ' + _t('Users'); return '👥 ' + _t('Users');
} }
setUserList(users) { setUserListFromRoom(room: Room) {
this.users = users; const events = room.getLiveTimeline().getEvents();
const lastSpoken = {};
for(const event of events) {
lastSpoken[event.getSender()] = event.getTs();
}
const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = room.getJoinedMembers().filter((member) => {
if (member.userId !== currentUserId) return true;
});
this.users = _sortBy(this.users, (user) => 1E20 - lastSpoken[user.userId] || 1E20);
this.matcher.setObjects(this.users);
}
onUserSpoke(user: RoomMember) {
if(user.userId === MatrixClientPeg.get().credentials.userId) return;
// Probably unsafe to compare by reference here?
_pull(this.users, user);
this.users.splice(0, 0, user);
this.matcher.setObjects(this.users);
} }
static getInstance(): UserProvider { static getInstance(): UserProvider {

View file

@ -557,7 +557,12 @@ module.exports = React.createClass({
this._onLoggedOut(); this._onLoggedOut();
break; break;
case 'will_start_client': case 'will_start_client':
this._onWillStartClient(); this.setState({ready: false}, () => {
// if the client is about to start, we are, by definition, not ready.
// Set ready to false now, then it'll be set to true when the sync
// listener we set below fires.
this._onWillStartClient();
});
break; break;
case 'new_version': case 'new_version':
this.onVersion( this.onVersion(
@ -1021,10 +1026,6 @@ module.exports = React.createClass({
*/ */
_onWillStartClient() { _onWillStartClient() {
const self = this; const self = this;
// if the client is about to start, we are, by definition, not ready.
// Set ready to false now, then it'll be set to true when the sync
// listener we set below fires.
this.setState({ready: false});
// reset the 'have completed first sync' flag, // reset the 'have completed first sync' flag,
// since we're about to start the client and therefore about // since we're about to start the client and therefore about

View file

@ -234,8 +234,6 @@ module.exports = React.createClass({
// making it impossible to indicate a newly joined room. // making it impossible to indicate a newly joined room.
const room = this.state.room; const room = this.state.room;
if (room) { if (room) {
this._updateAutoComplete(room);
this.tabComplete.loadEntries(room);
this.setState({ this.setState({
unsentMessageError: this._getUnsentMessageError(room), unsentMessageError: this._getUnsentMessageError(room),
}); });
@ -500,8 +498,7 @@ module.exports = React.createClass({
// and that has probably just changed // and that has probably just changed
if (ev.sender) { if (ev.sender) {
this.tabComplete.onMemberSpoke(ev.sender); this.tabComplete.onMemberSpoke(ev.sender);
// nb. we don't need to update the new autocomplete here since UserProvider.getInstance().onUserSpoke(ev.sender);
// its results are currently ordered purely by search score.
} }
}, },
@ -524,6 +521,8 @@ module.exports = React.createClass({
this._warnAboutEncryption(room); this._warnAboutEncryption(room);
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
this.tabComplete.loadEntries(room);
UserProvider.getInstance().setUserListFromRoom(room);
}, },
_warnAboutEncryption: function(room) { _warnAboutEncryption: function(room) {
@ -700,7 +699,7 @@ module.exports = React.createClass({
// refresh the tab complete list // refresh the tab complete list
this.tabComplete.loadEntries(this.state.room); this.tabComplete.loadEntries(this.state.room);
this._updateAutoComplete(this.state.room); UserProvider.getInstance().setUserListFromRoom(this.state.room);
// if we are now a member of the room, where we were not before, that // if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking // means we have finished joining a room we were previously peeking
@ -1425,14 +1424,6 @@ module.exports = React.createClass({
} }
}, },
_updateAutoComplete: function(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
UserProvider.getInstance().setUserList(members);
},
render: function() { render: function() {
const RoomHeader = sdk.getComponent('rooms.RoomHeader'); const RoomHeader = sdk.getComponent('rooms.RoomHeader');
const MessageComposer = sdk.getComponent('rooms.MessageComposer'); const MessageComposer = sdk.getComponent('rooms.MessageComposer');

View file

@ -642,6 +642,10 @@ module.exports = React.createClass({
}, },
_renderUserInterfaceSettings: function() { _renderUserInterfaceSettings: function() {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = (e) =>
UserSettingsStore.setLocalSetting('autocompleteDelay', + e.target.value);
return ( return (
<div> <div>
<h3>{ _t("User Interface") }</h3> <h3>{ _t("User Interface") }</h3>
@ -649,8 +653,21 @@ module.exports = React.createClass({
{ this._renderUrlPreviewSelector() } { this._renderUrlPreviewSelector() }
{ SETTINGS_LABELS.map( this._renderSyncedSetting ) } { SETTINGS_LABELS.map( this._renderSyncedSetting ) }
{ THEMES.map( this._renderThemeSelector ) } { THEMES.map( this._renderThemeSelector ) }
<table>
<tbody>
<tr>
<td><strong>{_t('Autocomplete Delay (ms):')}</strong></td>
<td>
<input
type="number"
defaultValue={UserSettingsStore.getLocalSetting('autocompleteDelay', 200)}
onChange={onChange}
/>
</td>
</tr>
</tbody>
</table>
{ this._renderLanguageSetting() } { this._renderLanguageSetting() }
</div> </div>
</div> </div>
); );

View file

@ -69,10 +69,19 @@ class PasswordLogin extends React.Component {
onSubmitForm(ev) { onSubmitForm(ev) {
ev.preventDefault(); ev.preventDefault();
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) {
this.props.onSubmit(
'', // XXX: Synapse breaks if you send null here:
this.state.phoneCountry,
this.state.phoneNumber,
this.state.password,
);
return;
}
this.props.onSubmit( this.props.onSubmit(
this.state.username, this.state.username,
this.state.phoneCountry, null,
this.state.phoneNumber, null,
this.state.password, this.state.password,
); );
} }

View file

@ -6,6 +6,7 @@ import isEqual from 'lodash/isEqual';
import sdk from '../../../index'; import sdk from '../../../index';
import type {Completion} from '../../../autocomplete/Autocompleter'; import type {Completion} from '../../../autocomplete/Autocompleter';
import Q from 'q'; import Q from 'q';
import UserSettingsStore from '../../../UserSettingsStore';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import {getCompletions} from '../../../autocomplete/Autocompleter';
@ -58,7 +59,7 @@ export default class Autocomplete extends React.Component {
return; return;
} }
const completionList = flatMap(completions, provider => provider.completions); const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty. // Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED; let selectionOffset = COMPOSER_SELECTED;
@ -69,27 +70,35 @@ export default class Autocomplete extends React.Component {
const currentSelection = this.state.selectionOffset === 0 ? null : const currentSelection = this.state.selectionOffset === 0 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion; this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex( selectionOffset = completionList.findIndex(
completion => completion.completion === currentSelection); (completion) => completion.completion === currentSelection);
if (selectionOffset === -1) { if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED; selectionOffset = COMPOSER_SELECTED;
} else { } else {
selectionOffset++; // selectionOffset is 1-indexed! selectionOffset++; // selectionOffset is 1-indexed!
} }
} else {
// If no completions were returned, we should turn off force completion.
forceComplete = false;
} }
let hide = this.state.hide; let hide = this.state.hide;
// These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern // These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern
const oldMatches = this.state.completions.map(completion => !!completion.command.command), const oldMatches = this.state.completions.map((completion) => !!completion.command.command),
newMatches = completions.map(completion => !!completion.command.command); newMatches = completions.map((completion) => !!completion.command.command);
// So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one // So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one
if (!isEqual(oldMatches, newMatches)) { if (!isEqual(oldMatches, newMatches)) {
hide = false; hide = false;
} }
const autocompleteDelay = UserSettingsStore.getSyncedSetting('autocompleteDelay', 200);
// We had no completions before, but do now, so we should apply our display delay here
if (this.state.completionList.length === 0 && completionList.length > 0 &&
!forceComplete && autocompleteDelay > 0) {
await Q.delay(autocompleteDelay);
}
// Force complete is turned off each time since we can't edit the query in that case
forceComplete = false;
this.setState({ this.setState({
completions, completions,
completionList, completionList,
@ -149,6 +158,7 @@ export default class Autocomplete extends React.Component {
const done = Q.defer(); const done = Q.defer();
this.setState({ this.setState({
forceComplete: true, forceComplete: true,
hide: false,
}, () => { }, () => {
this.complete(this.props.query, this.props.selection).then(() => { this.complete(this.props.query, this.props.selection).then(() => {
done.resolve(); done.resolve();
@ -169,7 +179,7 @@ export default class Autocomplete extends React.Component {
} }
setSelection(selectionOffset: number) { setSelection(selectionOffset: number) {
this.setState({selectionOffset}); this.setState({selectionOffset, hide: false});
} }
componentDidUpdate() { componentDidUpdate() {
@ -185,21 +195,24 @@ export default class Autocomplete extends React.Component {
} }
} }
setState(state, func) {
super.setState(state, func);
}
render() { render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText'); const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 1; let position = 1;
let renderedCompletions = this.state.completions.map((completionResult, i) => { const renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => { const completions = completionResult.completions.map((completion, i) => {
const className = classNames('mx_Autocomplete_Completion', { const className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset, 'selected': position === this.state.selectionOffset,
}); });
let componentPosition = position; const componentPosition = position;
position++; position++;
let onMouseOver = () => this.setSelection(componentPosition); const onMouseOver = () => this.setSelection(componentPosition);
let onClick = () => { const onClick = () => {
this.setSelection(componentPosition); this.setSelection(componentPosition);
this.onCompletionClicked(); this.onCompletionClicked();
}; };
@ -220,7 +233,7 @@ export default class Autocomplete extends React.Component {
{completionResult.provider.renderCompletions(completions)} {completionResult.provider.renderCompletions(completions)}
</div> </div>
) : null; ) : null;
}).filter(completion => !!completion); }).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? ( return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}> <div className="mx_Autocomplete" ref={(e) => this.container = e}>

View file

@ -20,7 +20,6 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType, convertFromRaw, convertToRaw, Modifier, EditorChangeType,
getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js';
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames'; import classNames from 'classnames';
import escape from 'lodash/escape'; import escape from 'lodash/escape';
import Q from 'q'; import Q from 'q';
@ -41,6 +40,7 @@ import * as HtmlUtils from '../../../HtmlUtils';
import Autocomplete from './Autocomplete'; import Autocomplete from './Autocomplete';
import {Completion} from "../../../autocomplete/Autocompleter"; import {Completion} from "../../../autocomplete/Autocompleter";
import Markdown from '../../../Markdown'; import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import {onSendMessageFailed} from './MessageComposerInputOld'; import {onSendMessageFailed} from './MessageComposerInputOld';
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
@ -58,6 +58,29 @@ function stateToMarkdown(state) {
* The textInput part of the MessageComposer * The textInput part of the MessageComposer
*/ */
export default class MessageComposerInput extends React.Component { export default class MessageComposerInput extends React.Component {
static propTypes = {
tabComplete: React.PropTypes.any,
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: React.PropTypes.func,
// js-sdk Room object
room: React.PropTypes.object.isRequired,
// called with current plaintext content (as a string) whenever it changes
onContentChanged: React.PropTypes.func,
onUpArrow: React.PropTypes.func,
onDownArrow: React.PropTypes.func,
// attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func,
onInputStateChanged: React.PropTypes.func,
};
static getKeyBinding(e: SyntheticKeyboardEvent): string { static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes // C-m => Toggles between rich text and markdown modes
if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
@ -77,6 +100,7 @@ export default class MessageComposerInput extends React.Component {
client: MatrixClient; client: MatrixClient;
autocomplete: Autocomplete; autocomplete: Autocomplete;
historyManager: ComposerHistoryManager;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -84,7 +108,6 @@ export default class MessageComposerInput extends React.Component {
this.handleReturn = this.handleReturn.bind(this); this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onEditorContentChanged = this.onEditorContentChanged.bind(this); this.onEditorContentChanged = this.onEditorContentChanged.bind(this);
this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this); this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this); this.onTab = this.onTab.bind(this);
@ -119,7 +142,7 @@ export default class MessageComposerInput extends React.Component {
*/ */
createEditorState(richText: boolean, contentState: ?ContentState): EditorState { createEditorState(richText: boolean, contentState: ?ContentState): EditorState {
let decorators = richText ? RichText.getScopedRTDecorators(this.props) : let decorators = richText ? RichText.getScopedRTDecorators(this.props) :
RichText.getScopedMDDecorators(this.props), RichText.getScopedMDDecorators(this.props),
compositeDecorator = new CompositeDecorator(decorators); compositeDecorator = new CompositeDecorator(decorators);
let editorState = null; let editorState = null;
@ -132,110 +155,13 @@ export default class MessageComposerInput extends React.Component {
return EditorState.moveFocusToEnd(editorState); return EditorState.moveFocusToEnd(editorState);
} }
componentWillMount() {
const component = this;
this.sentHistory = {
// The list of typed messages. Index 0 is more recent
data: [],
// The position in data currently displayed
position: -1,
// The room the history is for.
roomId: null,
// The original text before they hit UP
originalText: null,
// The textarea element to set text to.
element: null,
init: function(element, roomId) {
this.roomId = roomId;
this.element = element;
this.position = -1;
var storedData = window.sessionStorage.getItem(
"mx_messagecomposer_history_" + roomId
);
if (storedData) {
this.data = JSON.parse(storedData);
}
if (this.roomId) {
this.setLastTextEntry();
}
},
push: function(text) {
// store a message in the sent history
this.data.unshift(text);
window.sessionStorage.setItem(
"mx_messagecomposer_history_" + this.roomId,
JSON.stringify(this.data)
);
// reset history position
this.position = -1;
this.originalText = null;
},
// move in the history. Returns true if we managed to move.
next: function(offset) {
if (this.position === -1) {
// user is going into the history, save the current line.
this.originalText = this.element.value;
}
else {
// user may have modified this line in the history; remember it.
this.data[this.position] = this.element.value;
}
if (offset > 0 && this.position === (this.data.length - 1)) {
// we've run out of history
return false;
}
// retrieve the next item (bounded).
var newPosition = this.position + offset;
newPosition = Math.max(-1, newPosition);
newPosition = Math.min(newPosition, this.data.length - 1);
this.position = newPosition;
if (this.position !== -1) {
// show the message
this.element.value = this.data[this.position];
}
else if (this.originalText !== undefined) {
// restore the original text the user was typing.
this.element.value = this.originalText;
}
return true;
},
saveLastTextEntry: function() {
// 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!
let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent()));
window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON);
},
setLastTextEntry: function() {
let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId);
if (contentJSON) {
let content = convertFromRaw(JSON.parse(contentJSON));
component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content));
}
},
};
}
componentDidMount() { componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
this.sentHistory.init( this.historyManager = new ComposerHistoryManager(this.props.room.roomId);
this.refs.editor,
this.props.room.roomId
);
} }
componentWillUnmount() { componentWillUnmount() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
this.sentHistory.saveLastTextEntry();
} }
componentWillUpdate(nextProps, nextState) { componentWillUpdate(nextProps, nextState) {
@ -247,8 +173,8 @@ export default class MessageComposerInput extends React.Component {
} }
} }
onAction(payload) { onAction = (payload) => {
let editor = this.refs.editor; const editor = this.refs.editor;
let contentState = this.state.editorState.getCurrentContent(); let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) { switch (payload.action) {
@ -262,22 +188,22 @@ export default class MessageComposerInput extends React.Component {
contentState = Modifier.replaceText( contentState = Modifier.replaceText(
contentState, contentState,
this.state.editorState.getSelection(), this.state.editorState.getSelection(),
`${payload.displayname}: ` `${payload.displayname}: `,
); );
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
this.onEditorContentChanged(editorState); this.onEditorContentChanged(editorState);
editor.focus(); editor.focus();
} }
break; break;
case 'quote': { case 'quote': {
let {body, formatted_body} = payload.event.getContent(); let {body, formatted_body} = payload.event.getContent();
formatted_body = formatted_body || escape(body); formatted_body = formatted_body || escape(body);
if (formatted_body) { if (formatted_body) {
let content = RichText.HTMLtoContentState(`<blockquote>${formatted_body}</blockquote>`); let content = RichText.htmlToContentState(`<blockquote>${formatted_body}</blockquote>`);
if (!this.state.isRichtextEnabled) { if (!this.state.isRichtextEnabled) {
content = ContentState.createFromText(stateToMarkdown(content)); content = ContentState.createFromText(RichText.stateToMarkdown(content));
} }
const blockMap = content.getBlockMap(); const blockMap = content.getBlockMap();
@ -291,14 +217,14 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) { if (this.state.isRichtextEnabled) {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
} }
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
this.onEditorContentChanged(editorState); this.onEditorContentChanged(editorState);
editor.focus(); editor.focus();
} }
} }
break; break;
} }
} };
onTypingActivity() { onTypingActivity() {
this.isTyping = true; this.isTyping = true;
@ -318,7 +244,7 @@ export default class MessageComposerInput extends React.Component {
startUserTypingTimer() { startUserTypingTimer() {
this.stopUserTypingTimer(); this.stopUserTypingTimer();
var self = this; const self = this;
this.userTypingTimer = setTimeout(function() { this.userTypingTimer = setTimeout(function() {
self.isTyping = false; self.isTyping = false;
self.sendTyping(self.isTyping); self.sendTyping(self.isTyping);
@ -335,7 +261,7 @@ export default class MessageComposerInput extends React.Component {
startServerTypingTimer() { startServerTypingTimer() {
if (!this.serverTypingTimer) { if (!this.serverTypingTimer) {
var self = this; const self = this;
this.serverTypingTimer = setTimeout(function() { this.serverTypingTimer = setTimeout(function() {
if (self.isTyping) { if (self.isTyping) {
self.sendTyping(self.isTyping); self.sendTyping(self.isTyping);
@ -356,7 +282,7 @@ export default class MessageComposerInput extends React.Component {
if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return; if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return;
MatrixClientPeg.get().sendTyping( MatrixClientPeg.get().sendTyping(
this.props.room.roomId, this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT this.isTyping, TYPING_SERVER_TIMEOUT,
).done(); ).done();
} }
@ -367,60 +293,80 @@ export default class MessageComposerInput extends React.Component {
} }
} }
// Called by Draft to change editor contents, and by setEditorState // Called by Draft to change editor contents
onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) { onEditorContentChanged = (editorState: EditorState) => {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState); editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
const contentChanged = Q.defer(); /* Since a modification was made, set originalEditorState to null, since newState is now our original */
/* If a modification was made, set originalEditorState to null, since newState is now our original */
this.setState({ this.setState({
editorState, editorState,
originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState, originalEditorState: null,
}, () => contentChanged.resolve()); });
};
if (editorState.getCurrentContent().hasText()) { /**
this.onTypingActivity(); * We're overriding setState here because it's the most convenient way to monitor changes to the editorState.
} else { * Doing it using a separate function that calls setState is a possibility (and was the old approach), but that
this.onFinishedTyping(); * approach requires a callback and an extra setState whenever trying to set multiple state properties.
*
* @param state
* @param callback
*/
setState(state, callback) {
if (state.editorState != null) {
state.editorState = RichText.attachImmutableEntitiesToEmoji(
state.editorState);
if (state.editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
} else {
this.onFinishedTyping();
}
if (!state.hasOwnProperty('originalEditorState')) {
state.originalEditorState = null;
}
} }
if (this.props.onContentChanged) { super.setState(state, () => {
const textContent = editorState.getCurrentContent().getPlainText(); if (callback != null) {
const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), callback();
editorState.getCurrentContent().getBlocksAsArray()); }
this.props.onContentChanged(textContent, selection); if (this.props.onContentChanged) {
} const textContent = this.state.editorState
return contentChanged.promise; .getCurrentContent().getPlainText();
} const selection = RichText.selectionStateToTextOffsets(
this.state.editorState.getSelection(),
setEditorState(editorState: EditorState) { this.state.editorState.getCurrentContent().getBlocksAsArray());
return this.onEditorContentChanged(editorState, false); this.props.onContentChanged(textContent, selection);
}
});
} }
enableRichtext(enabled: boolean) { enableRichtext(enabled: boolean) {
if (enabled === this.state.isRichtextEnabled) return;
let contentState = null; let contentState = null;
if (enabled) { if (enabled) {
const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText());
contentState = RichText.HTMLtoContentState(md.toHTML()); contentState = RichText.htmlToContentState(md.toHTML());
} else { } else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent());
if (markdown[markdown.length - 1] === '\n') { if (markdown[markdown.length - 1] === '\n') {
markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?)
} }
contentState = ContentState.createFromText(markdown); contentState = ContentState.createFromText(markdown);
} }
this.setEditorState(this.createEditorState(enabled, contentState)).then(() => { this.setState({
this.setState({ editorState: this.createEditorState(enabled, contentState),
isRichtextEnabled: enabled, isRichtextEnabled: enabled,
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
}); });
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
} }
handleKeyCommand(command: string): boolean { handleKeyCommand = (command: string): boolean => {
if (command === 'toggle-mode') { if (command === 'toggle-mode') {
this.enableRichtext(!this.state.isRichtextEnabled); this.enableRichtext(!this.state.isRichtextEnabled);
return true; return true;
@ -434,31 +380,35 @@ export default class MessageComposerInput extends React.Component {
const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'];
if (blockCommands.includes(command)) { if (blockCommands.includes(command)) {
this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command)); this.setState({
editorState: RichUtils.toggleBlockType(this.state.editorState, command),
});
} else if (command === 'strike') { } else if (command === 'strike') {
// this is the only inline style not handled by Draft by default // this is the only inline style not handled by Draft by default
this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH')); this.setState({
editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'),
});
} }
} else { } else {
let contentState = this.state.editorState.getCurrentContent(), let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection(); selection = this.state.editorState.getSelection();
let modifyFn = { const modifyFn = {
'bold': text => `**${text}**`, 'bold': (text) => `**${text}**`,
'italic': text => `*${text}*`, 'italic': (text) => `*${text}*`,
'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* 'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
'strike': text => `~~${text}~~`, 'strike': (text) => `<del>${text}</del>`,
'code': text => `\`${text}\``, 'code-block': (text) => `\`\`\`\n${text}\n\`\`\``,
'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''), 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''),
'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''),
'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''), 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''),
}[command]; }[command];
if (modifyFn) { if (modifyFn) {
newState = EditorState.push( newState = EditorState.push(
this.state.editorState, this.state.editorState,
RichText.modifyText(contentState, selection, modifyFn), RichText.modifyText(contentState, selection, modifyFn),
'insert-characters' 'insert-characters',
); );
} }
} }
@ -468,7 +418,7 @@ export default class MessageComposerInput extends React.Component {
} }
if (newState != null) { if (newState != null) {
this.setEditorState(newState); this.setState({editorState: newState});
return true; return true;
} }
@ -481,6 +431,13 @@ export default class MessageComposerInput extends React.Component {
return true; return true;
} }
const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState);
// If we're in any of these three types of blocks, shift enter should insert soft newlines
// And just enter should end the block
if(['blockquote', 'unordered-list-item', 'ordered-list-item'].includes(currentBlockType)) {
return false;
}
const contentState = this.state.editorState.getCurrentContent(); const contentState = this.state.editorState.getCurrentContent();
if (!contentState.hasText()) { if (!contentState.hasText()) {
return true; return true;
@ -489,11 +446,11 @@ export default class MessageComposerInput extends React.Component {
let contentText = contentState.getPlainText(), contentHTML; let contentText = contentState.getPlainText(), contentHTML;
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); const cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
if (cmd) { if (cmd) {
if (!cmd.error) { if (!cmd.error) {
this.setState({ this.setState({
editorState: this.createEditorState() editorState: this.createEditorState(),
}); });
} }
if (cmd.promise) { if (cmd.promise) {
@ -501,16 +458,15 @@ export default class MessageComposerInput extends React.Component {
console.log("Command success."); console.log("Command success.");
}, function(err) { }, function(err) {
console.error("Command failure: %s", err); console.error("Command failure: %s", err);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: _t("Server error"), title: _t("Server error"),
description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")), description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")),
}); });
}); });
} } else if (cmd.error) {
else if (cmd.error) {
console.error(cmd.error); console.error(cmd.error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: _t("Command error"), title: _t("Command error"),
description: cmd.error, description: cmd.error,
@ -521,7 +477,7 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) { if (this.state.isRichtextEnabled) {
contentHTML = HtmlUtils.stripParagraphs( contentHTML = HtmlUtils.stripParagraphs(
RichText.contentStateToHTML(contentState) RichText.contentStateToHTML(contentState),
); );
} else { } else {
const md = new Markdown(contentText); const md = new Markdown(contentText);
@ -543,12 +499,14 @@ export default class MessageComposerInput extends React.Component {
sendTextFn = this.client.sendEmoteMessage; sendTextFn = this.client.sendEmoteMessage;
} }
// XXX: We don't actually seem to use this history? this.historyManager.addItem(
this.sentHistory.push(contentHTML || contentText); this.state.isRichtextEnabled ? contentHTML : contentState.getPlainText(),
this.state.isRichtextEnabled ? 'html' : 'markdown');
let sendMessagePromise; let sendMessagePromise;
if (contentHTML) { if (contentHTML) {
sendMessagePromise = sendHtmlFn.call( sendMessagePromise = sendHtmlFn.call(
this.client, this.props.room.roomId, contentText, contentHTML this.client, this.props.room.roomId, contentText, contentHTML,
); );
} else { } else {
sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText); sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText);
@ -567,87 +525,106 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete.hide(); this.autocomplete.hide();
return true; return true;
} };
async onUpArrow(e) { onUpArrow = async (e) => {
const completion = this.autocomplete.onUpArrow(); const completion = this.autocomplete.onUpArrow();
if (completion != null) { if (completion == null) {
e.preventDefault(); const newContent = this.historyManager.getItem(-1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
} }
return await this.setDisplayedCompletion(completion);
}
async onDownArrow(e) {
const completion = this.autocomplete.onDownArrow();
e.preventDefault(); e.preventDefault();
return await this.setDisplayedCompletion(completion); return await this.setDisplayedCompletion(completion);
} };
onDownArrow = async (e) => {
const completion = this.autocomplete.onDownArrow();
if (completion == null) {
const newContent = this.historyManager.getItem(+1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
e.preventDefault();
return await this.setDisplayedCompletion(completion);
};
// tab and shift-tab are mapped to down and up arrow respectively // tab and shift-tab are mapped to down and up arrow respectively
async onTab(e) { onTab = async (e) => {
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes
const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); if (this.autocomplete.state.completionList.length === 0) {
if (!didTab && this.autocomplete) { await this.autocomplete.forceComplete();
this.autocomplete.forceComplete().then(() => { this.onDownArrow(e);
this.onDownArrow(e); } else {
}); await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
} }
} };
onEscape(e) { onEscape = async (e) => {
e.preventDefault(); e.preventDefault();
if (this.autocomplete) { if (this.autocomplete) {
this.autocomplete.onEscape(e); this.autocomplete.onEscape(e);
} }
this.setDisplayedCompletion(null); // restore originalEditorState await this.setDisplayedCompletion(null); // restore originalEditorState
} };
/* If passed null, restores the original editor content from state.originalEditorState. /* If passed null, restores the original editor content from state.originalEditorState.
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState. * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
*/ */
async setDisplayedCompletion(displayedCompletion: ?Completion): boolean { setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
const activeEditorState = this.state.originalEditorState || this.state.editorState; const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) { if (displayedCompletion == null) {
if (this.state.originalEditorState) { if (this.state.originalEditorState) {
this.setEditorState(this.state.originalEditorState); let editorState = this.state.originalEditorState;
// This is a workaround from https://github.com/facebook/draft-js/issues/458
// Due to the way we swap editorStates, Draft does not rerender at times
editorState = EditorState.forceSelection(editorState,
editorState.getSelection());
this.setState({editorState});
} }
return false; return false;
} }
const {range = {}, completion = ''} = displayedCompletion; const {range = {}, completion = ''} = displayedCompletion;
let contentState = Modifier.replaceText( const contentState = Modifier.replaceText(
activeEditorState.getCurrentContent(), activeEditorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()), RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()),
completion completion,
); );
let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters'); let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
const originalEditorState = activeEditorState; this.setState({editorState, originalEditorState: activeEditorState});
await this.setEditorState(editorState);
this.setState({originalEditorState});
// for some reason, doing this right away does not update the editor :( // for some reason, doing this right away does not update the editor :(
setTimeout(() => this.refs.editor.focus(), 50); // setTimeout(() => this.refs.editor.focus(), 50);
return true; return true;
} };
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) { onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
e.preventDefault(); // don't steal focus from the editor! e.preventDefault(); // don't steal focus from the editor!
const command = { const command = {
code: 'code-block', code: 'code-block',
quote: 'blockquote', quote: 'blockquote',
bullet: 'unordered-list-item', bullet: 'unordered-list-item',
numbullet: 'ordered-list-item', numbullet: 'ordered-list-item',
}[name] || name; }[name] || name;
this.handleKeyCommand(command); this.handleKeyCommand(command);
} }
/* returns inline style and block type of current SelectionState so MessageComposer can render formatting /* returns inline style and block type of current SelectionState so MessageComposer can render formatting
buttons. */ buttons. */
getSelectionInfo(editorState: EditorState) { getSelectionInfo(editorState: EditorState) {
const styleName = { const styleName = {
BOLD: 'bold', BOLD: 'bold',
@ -658,8 +635,8 @@ export default class MessageComposerInput extends React.Component {
const originalStyle = editorState.getCurrentInlineStyle().toArray(); const originalStyle = editorState.getCurrentInlineStyle().toArray();
const style = originalStyle const style = originalStyle
.map(style => styleName[style] || null) .map((style) => styleName[style] || null)
.filter(styleName => !!styleName); .filter((styleName) => !!styleName);
const blockName = { const blockName = {
'code-block': 'code', 'code-block': 'code',
@ -678,10 +655,10 @@ export default class MessageComposerInput extends React.Component {
}; };
} }
onMarkdownToggleClicked(e) { onMarkdownToggleClicked = (e) => {
e.preventDefault(); // don't steal focus from the editor! e.preventDefault(); // don't steal focus from the editor!
this.handleKeyCommand('toggle-mode'); this.handleKeyCommand('toggle-mode');
} };
render() { render() {
const activeEditorState = this.state.originalEditorState || this.state.editorState; const activeEditorState = this.state.originalEditorState || this.state.editorState;
@ -698,7 +675,7 @@ export default class MessageComposerInput extends React.Component {
} }
const className = classNames('mx_MessageComposer_input', { const className = classNames('mx_MessageComposer_input', {
mx_MessageComposer_input_empty: hidePlaceholder, mx_MessageComposer_input_empty: hidePlaceholder,
}); });
const content = activeEditorState.getCurrentContent(); const content = activeEditorState.getCurrentContent();
@ -713,7 +690,7 @@ export default class MessageComposerInput extends React.Component {
ref={(e) => this.autocomplete = e} ref={(e) => this.autocomplete = e}
onConfirm={this.setDisplayedCompletion} onConfirm={this.setDisplayedCompletion}
query={contentText} query={contentText}
selection={selection} /> selection={selection}/>
</div> </div>
<div className={className}> <div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor" <img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
@ -735,7 +712,7 @@ export default class MessageComposerInput extends React.Component {
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow} onDownArrow={this.onDownArrow}
onEscape={this.onEscape} onEscape={this.onEscape}
spellCheck={true} /> spellCheck={true}/>
</div> </div>
</div> </div>
); );

View file

@ -240,6 +240,7 @@
"demote": "demote", "demote": "demote",
"Deops user with given id": "Deops user with given id", "Deops user with given id": "Deops user with given id",
"Default": "Default", "Default": "Default",
"Define the power level of a user": "Define the power level of a user",
"Device already verified!": "Device already verified!", "Device already verified!": "Device already verified!",
"Device ID": "Device ID", "Device ID": "Device ID",
"Device ID:": "Device ID:", "Device ID:": "Device ID:",
@ -581,6 +582,7 @@
"Unable to restore previous session": "Unable to restore previous session", "Unable to restore previous session": "Unable to restore previous session",
"Unable to verify email address.": "Unable to verify email address.", "Unable to verify email address.": "Unable to verify email address.",
"Unban": "Unban", "Unban": "Unban",
"Unbans user with given id": "Unbans user with given id",
"%(senderName)s unbanned %(targetName)s.": "%(senderName)s unbanned %(targetName)s.", "%(senderName)s unbanned %(targetName)s.": "%(senderName)s unbanned %(targetName)s.",
"Unable to ascertain that the address this invite was sent to matches one associated with your account.": "Unable to ascertain that the address this invite was sent to matches one associated with your account.", "Unable to ascertain that the address this invite was sent to matches one associated with your account.": "Unable to ascertain that the address this invite was sent to matches one associated with your account.",
"Unable to capture screen": "Unable to capture screen", "Unable to capture screen": "Unable to capture screen",
@ -921,6 +923,7 @@
"You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.", "You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.",
"Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys.", "Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys.",
"Encryption key request": "Encryption key request", "Encryption key request": "Encryption key request",
"Autocomplete Delay (ms):": "Autocomplete Delay (ms):",
"This Home server does not support groups": "This Home server does not support groups", "This Home server does not support groups": "This Home server does not support groups",
"Loading device info...": "Loading device info..." "Loading device info...": "Loading device info..."
} }

View file

@ -99,17 +99,18 @@ describe('MessageComposerInput', () => {
}); });
it('should not change content unnecessarily on Markdown -> RTE conversion', () => { it('should not change content unnecessarily on Markdown -> RTE conversion', () => {
const spy = sinon.spy(client, 'sendTextMessage'); const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(false); mci.enableRichtext(false);
addTextToDraft('a'); addTextToDraft('a');
mci.handleKeyCommand('toggle-mode'); mci.handleKeyCommand('toggle-mode');
mci.handleReturn(sinon.stub()); mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true); expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('a'); expect(spy.args[0][1]).toEqual('a');
}); });
it('should send emoji messages in rich text', () => { it('should send emoji messages in rich text', () => {
const spy = sinon.spy(client, 'sendTextMessage'); const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(true); mci.enableRichtext(true);
addTextToDraft('☹'); addTextToDraft('☹');
mci.handleReturn(sinon.stub()); mci.handleReturn(sinon.stub());