Merge branches 'develop' and 't3chguy/add-missing-autocomplete-commands' of github.com:matrix-org/matrix-react-sdk into t3chguy/add-missing-autocomplete-commands

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

# Conflicts:
#	src/autocomplete/CommandProvider.js
#	src/i18n/strings/en_EN.json
This commit is contained in:
Michael Telatynski 2017-07-01 13:07:18 +01:00
commit 09f017fdd2
No known key found for this signature in database
GPG key ID: 0435A1D4BBD34D64
33 changed files with 2509 additions and 1292 deletions

6
.flowconfig Normal file
View file

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

View file

@ -33,8 +33,9 @@
"scripts": { "scripts": {
"reskindex": "node scripts/reskindex.js -h header", "reskindex": "node scripts/reskindex.js -h header",
"reskindex:watch": "node scripts/reskindex.js -h header -w", "reskindex:watch": "node scripts/reskindex.js -h header -w",
"build": "npm run reskindex && babel src -d lib --source-maps", "build": "npm run reskindex && babel src -d lib --source-maps --copy-files",
"build:watch": "babel src -w -d lib --source-maps", "build:watch": "babel src -w -d lib --source-maps --copy-files",
"emoji-data-strip": "node scripts/emoji-data-strip.js",
"start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"", "start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"",
"lint": "eslint src/", "lint": "eslint src/",
"lintall": "eslint src/ test/", "lintall": "eslint src/ test/",
@ -51,7 +52,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 +65,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,23 @@
#!/usr/bin/env node
const EMOJI_DATA = require('emojione/emoji.json');
const fs = require('fs');
const output = Object.keys(EMOJI_DATA).map(
(key) => {
const datum = EMOJI_DATA[key];
const newDatum = {
name: datum.name,
shortname: datum.shortname,
category: datum.category,
emoji_order: datum.emoji_order,
};
if (datum.aliases_ascii.length > 0) {
newDatum.aliases_ascii = datum.aliases_ascii;
}
return newDatum;
}
);
// Write to a file in src. Changes should be checked into git. This file is copied by
// babel using --copy-files
fs.writeFileSync('./src/stripped-emoji.json', JSON.stringify(output));

View file

@ -0,0 +1,82 @@
//@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 = 0;
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let item;
for(; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)),
);
}
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

@ -84,7 +84,7 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
} }
export function stripParagraphs(html: string): string { export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div'); const contentDiv = document.createElement('div');
contentDiv.innerHTML = html; contentDiv.innerHTML = html;
@ -93,10 +93,21 @@ export function stripParagraphs(html: string): string {
} }
let contentHTML = ""; let contentHTML = "";
for (let i=0; i<contentDiv.children.length; i++) { for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i]; const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') { if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML + '<br />'; contentHTML += element.innerHTML;
// Don't add a <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> is
// redundant. This is a workaround for a bug in draft-js-export-html:
// https://github.com/sstur/draft-js-export-html/issues/62
contentHTML += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} else { } else {
const temp = document.createElement('div'); const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true)); temp.appendChild(element.cloneNode(true));
@ -124,6 +135,7 @@ var sanitizeHtmlParams = {
// would make sense if we did // would make sense if we did
img: ['src'], img: ['src'],
ol: ['start'], ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
}, },
// Lots of these won't come up by default because we don't allow them // Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
@ -165,6 +177,19 @@ var sanitizeHtmlParams = {
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName: tagName, attribs : attribs }; return { tagName: tagName, attribs : attribs };
}, },
'code': function(tagName, attribs) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
let classes = attribs.class.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-');
});
attribs.class = classes.join(' ');
}
return {
tagName: tagName,
attribs: attribs,
};
},
'*': function(tagName, attribs) { '*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span // Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming // because attributes are stripped after transforming

View file

@ -30,7 +30,30 @@ module.exports = {
RIGHT: 39, RIGHT: 39,
DOWN: 40, DOWN: 40,
DELETE: 46, DELETE: 46,
KEY_A: 65,
KEY_B: 66,
KEY_C: 67,
KEY_D: 68, KEY_D: 68,
KEY_E: 69, KEY_E: 69,
KEY_F: 70,
KEY_G: 71,
KEY_H: 72,
KEY_I: 73,
KEY_J: 74,
KEY_K: 75,
KEY_L: 76,
KEY_M: 77, KEY_M: 77,
KEY_N: 78,
KEY_O: 79,
KEY_P: 80,
KEY_Q: 81,
KEY_R: 82,
KEY_S: 83,
KEY_T: 84,
KEY_U: 85,
KEY_V: 86,
KEY_W: 87,
KEY_X: 88,
KEY_Y: 89,
KEY_Z: 90,
}; };

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

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -109,6 +110,76 @@ Example:
response: 78 response: 78
} }
set_widget
----------
Set a new widget in the room. Clobbers based on the ID.
Request:
- `room_id` (String) is the room to set the widget in.
- `widget_id` (String) is the ID of the widget to add (or replace if it already exists).
It can be an arbitrary UTF8 string and is purely for distinguishing between widgets.
- `url` (String) is the URL that clients should load in an iframe to run the widget.
All widgets must have a valid URL. If the URL is `null` (not `undefined`), the
widget will be removed from the room.
- `type` (String) is the type of widget, which is provided as a hint for matrix clients so they
can configure/lay out the widget in different ways. All widgets must have a type.
- `name` (String) is an optional human-readable string about the widget.
- `data` (Object) is some optional data about the widget, and can contain arbitrary key/value pairs.
Response:
{
success: true
}
Example:
{
action: "set_widget",
room_id: "!foo:bar",
widget_id: "abc123",
url: "http://widget.url",
type: "example",
response: {
success: true
}
}
get_widgets
-----------
Get a list of all widgets in the room. The response is the `content` field
of the state event.
Request:
- `room_id` (String) is the room to get the widgets in.
Response:
{
$widget_id: {
type: "example",
url: "http://widget.url",
name: "Example Widget",
data: {
key: "val"
}
},
$widget_id: { ... }
}
Example:
{
action: "get_widgets",
room_id: "!foo:bar",
widget_id: "abc123",
url: "http://widget.url",
type: "example",
response: {
$widget_id: {
type: "example",
url: "http://widget.url",
name: "Example Widget",
data: {
key: "val"
}
},
$widget_id: { ... }
}
}
membership_state AND bot_options membership_state AND bot_options
-------------------------------- --------------------------------
@ -191,6 +262,84 @@ function inviteUser(event, roomId, userId) {
}); });
} }
function setWidget(event, roomId) {
const widgetId = event.data.widget_id;
const widgetType = event.data.type;
const widgetUrl = event.data.url;
const widgetName = event.data.name; // optional
const widgetData = event.data.data; // optional
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
// both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields."));
return;
}
if (widgetUrl !== null) { // if url is null it is being deleted, don't need to check name/type/etc
// check types of fields
if (widgetName !== undefined && typeof widgetName !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'name' must be a string."));
return;
}
if (widgetData !== undefined && !(widgetData instanceof Object)) {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object."));
return;
}
if (typeof widgetType !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string."));
return;
}
if (typeof widgetUrl !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'url' must be a string or null."));
return;
}
}
// TODO: same dance we do for power levels. It'd be nice if the JS SDK had helper methods to do this.
client.getStateEvent(roomId, "im.vector.modular.widgets", "").then((widgets) => {
if (widgetUrl === null) {
delete widgets[widgetId];
}
else {
widgets[widgetId] = {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
};
}
return client.sendStateEvent(roomId, "im.vector.modular.widgets", widgets);
}, (err) => {
if (err.errcode === "M_NOT_FOUND") {
return client.sendStateEvent(roomId, "im.vector.modular.widgets", {
[widgetId]: {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
}
});
}
throw err;
}).done(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, _t('Failed to send request.'), err);
});
}
function getWidgets(event, roomId) {
returnStateEvent(event, roomId, "im.vector.modular.widgets", "");
}
function setPlumbingState(event, roomId, status) { function setPlumbingState(event, roomId, status) {
if (typeof status !== 'string') { if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string'); throw new Error('Plumbing state status should be a string');
@ -367,7 +516,7 @@ const onMessage = function(event) {
return; return;
} }
// Getting join rules does not require userId // These APIs don't require userId
if (event.data.action === "join_rules_state") { if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId); getJoinRules(event, roomId);
return; return;
@ -377,6 +526,12 @@ const onMessage = function(event) {
} else if (event.data.action === "get_membership_count") { } else if (event.data.action === "get_membership_count") {
getMembershipCount(event, roomId); getMembershipCount(event, roomId);
return; return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
} else if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} }
if (!userId) { if (!userId) {
@ -409,12 +564,27 @@ const onMessage = function(event) {
}); });
}; };
let listenerCount = 0;
module.exports = { module.exports = {
startListening: function() { startListening: function() {
if (listenerCount === 0) {
window.addEventListener("message", onMessage, false); window.addEventListener("message", onMessage, false);
}
listenerCount += 1;
}, },
stopListening: function() { stopListening: function() {
listenerCount -= 1;
if (listenerCount === 0) {
window.removeEventListener("message", onMessage); window.removeEventListener("message", onMessage);
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"ScalarMessaging: mismatched startListening / stopListening detected." +
" Negative count"
);
console.error(e);
}
}, },
}; };

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

@ -30,11 +30,17 @@ export default {
id: 'rich_text_editor', id: 'rich_text_editor',
default: false, default: false,
}, },
{
name: "-",
id: 'matrix_apps',
default: false,
},
], ],
// horrible but it works. The locality makes this somewhat more palatable. // horrible but it works. The locality makes this somewhat more palatable.
doTranslations: function() { doTranslations: function() {
this.LABS_FEATURES[0].name = _t("New Composer & Autocomplete"); this.LABS_FEATURES[0].name = _t("New Composer & Autocomplete");
this.LABS_FEATURES[1].name = _t("Matrix Apps");
}, },
loadProfileInfo: function() { loadProfileInfo: function() {

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';
// TODO merge this with the factory mechanics of SlashCommands? // TODO merge this with the factory mechanics of SlashCommands?
@ -36,13 +36,13 @@ const COMMANDS = [
}, },
{ {
command: '/unban', command: '/unban',
args: '<user-id> [reason]', args: '<user-id>',
description: 'Unbans user with given id', description: 'Unbans user with given id',
}, },
{ {
command: '/op', command: '/op',
args: '<user-id>', args: '<userId> [<power-level>]',
description: 'Ops user with given id', description: 'Define the power level of a user',
}, },
{ {
command: '/deop', command: '/deop',
@ -61,8 +61,8 @@ const COMMANDS = [
}, },
{ {
command: '/part', command: '/part',
args: '<room-alias>', args: '[<room-alias>]',
description: 'Leaves room with given alias', description: 'Leave room',
}, },
{ {
command: '/topic', command: '/topic',
@ -104,7 +104,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'],
}); });
} }
@ -113,7 +113,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

@ -18,21 +18,55 @@ 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 {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import {emojioneList, shortnameToImage, shortnameToUnicode, asciiRegexp} 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; import EmojiData from '../stripped-emoji.json';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const LIMIT = 20;
const CATEGORY_ORDER = [
'people',
'food',
'objects',
'activity',
'nature',
'travel',
'flags',
'symbols',
'unicode9',
'modifier',
];
// Match for ":wink:" or ascii-style ";-)" provided by emojione
const EMOJI_REGEX = new RegExp('(' + asciiRegexp + '|:\\w*:?)', 'g');
const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort(
(a, b) => {
if (a.category === b.category) {
return a.emoji_order - b.emoji_order;
}
return CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
},
).map((a) => {
return {
name: a.name,
shortname: a.shortname,
aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '',
};
});
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: ['aliases_ascii', 'shortname', 'name'],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
});
} }
async getCompletions(query: string, selection: SelectionRange) { async getCompletions(query: string, selection: SelectionRange) {
@ -41,8 +75,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,
@ -51,7 +85,7 @@ export default class EmojiProvider extends AutocompleteProvider {
), ),
range, range,
}; };
}).slice(0, 8); }).slice(0, LIMIT);
} }
return completions; return completions;
} }

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,92 @@
//@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);
// By default, we remove any non-alphanumeric characters ([^A-Za-z0-9_]) from the
// query and the value being queried before matching
if (this.options.shouldMatchWordsOnly === undefined) {
this.options.shouldMatchWordsOnly = true;
}
}
setObjects(objects: Array<Object>) {
this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys);
}
match(query: String): Array<Object> {
query = query.toLowerCase();
if (this.options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
}
const results = _sortedUniq(_sortBy(_flatMap(this.keyMap.keys, (key) => {
let resultKey = key.toLowerCase();
if (this.options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}
return resultKey.indexOf(query) !== -1 ? 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,10 +30,8 @@ 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([], {
});
this.fuse = new Fuse([], {
keys: ['name', 'roomId', 'aliases'], 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

@ -47,13 +47,12 @@ import UserProvider from '../../autocomplete/UserProvider';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
var DEBUG = false; let DEBUG = false;
let debuglog = function() {};
if (DEBUG) { if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console); debuglog = console.log.bind(console);
} else {
var debuglog = function() {};
} }
module.exports = React.createClass({ module.exports = React.createClass({
@ -113,6 +112,7 @@ module.exports = React.createClass({
callState: null, callState: null,
guestsCanJoin: false, guestsCanJoin: false,
canPeek: false, canPeek: false,
showApps: false,
// error object, as from the matrix client/server API // error object, as from the matrix client/server API
// If we failed to load information about the room, // If we failed to load information about the room,
@ -234,10 +234,9 @@ 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),
showApps: this._shouldShowApps(room),
}); });
this._onRoomLoaded(room); this._onRoomLoaded(room);
} }
@ -275,6 +274,11 @@ module.exports = React.createClass({
} }
}, },
_shouldShowApps: function(room) {
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets', '');
return appsStateEvents && Object.keys(appsStateEvents.getContent()).length > 0;
},
componentDidMount: function() { componentDidMount: function() {
var call = this._getCallForRoom(); var call = this._getCallForRoom();
var callState = call ? call.call_state : "ended"; var callState = call ? call.call_state : "ended";
@ -455,9 +459,14 @@ module.exports = React.createClass({
this._updateConfCallNotification(); this._updateConfCallNotification();
this.setState({ this.setState({
callState: callState callState: callState,
}); });
break;
case 'appsDrawer':
this.setState({
showApps: payload.show,
});
break; break;
} }
}, },
@ -500,8 +509,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 +532,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 +710,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 +1435,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');
@ -1613,11 +1615,13 @@ module.exports = React.createClass({
var auxPanel = ( var auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room} <AuxPanel ref="auxPanel" room={this.state.room}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler} conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile} draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification} displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight} maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize} > onResize={this.onChildResize}
showApps={this.state.showApps && !this.state.editingRoomSettings} >
{ aux } { aux }
</AuxPanel> </AuxPanel>
); );
@ -1630,8 +1634,14 @@ module.exports = React.createClass({
if (canSpeak) { if (canSpeak) {
messageComposer = messageComposer =
<MessageComposer <MessageComposer
room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile} room={this.state.room}
callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>; onResize={this.onChildResize}
uploadFile={this.uploadFile}
callState={this.state.callState}
tabComplete={this.tabComplete}
opacity={ this.props.opacity }
showApps={ this.state.showApps }
/>;
} }
// TODO: Why aren't we storing the term/scope/count in this format // TODO: Why aren't we storing the term/scope/count in this format

View file

@ -93,6 +93,10 @@ const SETTINGS_LABELS = [
id: 'disableMarkdown', id: 'disableMarkdown',
label: 'Disable markdown formatting', label: 'Disable markdown formatting',
}, },
{
id: 'enableSyntaxHighlightLanguageDetection',
label: 'Enable automatic language detection for syntax highlighting',
},
/* /*
{ {
id: 'useFixedWidthFont', id: 'useFixedWidthFont',
@ -642,6 +646,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 +657,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

@ -0,0 +1,102 @@
/*
Copyright 2017 Vector Creations 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';
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
export default React.createClass({
displayName: 'AppTile',
propTypes: {
id: React.PropTypes.string.isRequired,
url: React.PropTypes.string.isRequired,
name: React.PropTypes.string.isRequired,
room: React.PropTypes.object.isRequired,
},
getDefaultProps: function() {
return {
url: "",
};
},
_onEditClick: function() {
console.log("Edit widget %s", this.props.id);
},
_onDeleteClick: function() {
console.log("Delete widget %s", this.props.id);
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
if (!appsStateEvents) {
return;
}
const appsStateEvent = appsStateEvents.getContent();
if (appsStateEvent[this.props.id]) {
delete appsStateEvent[this.props.id];
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
appsStateEvent,
'',
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
});
}
},
formatAppTileName: function() {
let appTileName = "No name";
if(this.props.name && this.props.name.trim()) {
appTileName = this.props.name.trim();
appTileName = appTileName[0].toUpperCase() + appTileName.slice(1).toLowerCase();
}
return appTileName;
},
render: function() {
return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
<div className="mx_AppTileMenuBar">
{this.formatAppTileName()}
<span className="mx_AppTileMenuBarWidgets">
{/* Edit widget */}
{/* <img
src="img/edit.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8" alt="Edit"
onClick={this._onEditClick}
/> */}
{/* Delete widget */}
<img src="img/cancel.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget"
width="8" height="8" alt={_t("Cancel")}
onClick={this._onDeleteClick}
/>
</span>
</div>
<div className="mx_AppTileBody">
<iframe ref="appFrame" src={this.props.url} allowFullScreen="true"></iframe>
</div>
</div>
);
},
});

View file

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

View file

@ -79,7 +79,7 @@ module.exports = React.createClass({
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} else if (content.info.thumbnail_url) { } else if (content.info && content.info.thumbnail_url) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url);
} else { } else {
return null; return null;

View file

@ -29,6 +29,7 @@ import Modal from '../../../Modal';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import UserSettingsStore from "../../../UserSettingsStore";
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -90,8 +91,19 @@ module.exports = React.createClass({
setTimeout(() => { setTimeout(() => {
if (this._unmounted) return; if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
if (UserSettingsStore.getSyncedSetting("enableSyntaxHighlightLanguageDetection", false)) {
highlight.highlightBlock(blocks[i])
} else {
// Only syntax highlight if there's a class starting with language-
let classes = blocks[i].className.split(/\s+/).filter(function (cl) {
return cl.startsWith('language-');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]); highlight.highlightBlock(blocks[i]);
} }
}
}
}, 10); }, 10);
} }
// add event handlers to the 'copy code' buttons // add event handlers to the 'copy code' buttons

View file

@ -0,0 +1,218 @@
/*
Copyright 2017 Vector Creations 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';
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'AppsDrawer',
propTypes: {
room: React.PropTypes.object.isRequired,
},
getInitialState: function() {
return {
apps: this._getApps(),
};
},
componentWillMount: function() {
ScalarMessaging.startListening();
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
componentDidMount: function() {
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
if (this.state.apps && this.state.apps.length < 1) {
this.onClickAddWidget();
}
// TODO -- Handle Scalar errors
// },
// (err) => {
// this.setState({
// scalar_error: err,
// });
});
}
},
componentWillUnmount: function() {
ScalarMessaging.stopListening();
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
},
/**
* Encodes a URI according to a set of template variables. Variables will be
* passed through encodeURIComponent.
* @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'.
* @param {Object} variables The key/value pairs to replace the template
* variables with. E.g. { "$bar": "baz" }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
encodeUri: function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
},
_initAppConfig: function(appId, app) {
const user = MatrixClientPeg.get().getUser(this.props.userId);
const params = {
'$matrix_user_id': this.props.userId,
'$matrix_room_id': this.props.room.roomId,
'$matrix_display_name': user ? user.displayName : this.props.userId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
};
if(app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
}
app.id = appId;
app.name = app.name || app.type;
app.url = this.encodeUri(app.url, params);
// switch(app.type) {
// case 'etherpad':
// app.queryParams = '?userName=' + this.props.userId +
// '&padId=' + this.props.room.roomId;
// break;
// case 'jitsi': {
//
// app.queryParams = '?confId=' + app.data.confId +
// '&displayName=' + encodeURIComponent(user.displayName) +
// '&avatarUrl=' + encodeURIComponent(MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl)) +
// '&email=' + encodeURIComponent(this.props.userId) +
// '&isAudioConf=' + app.data.isAudioConf;
//
// break;
// }
// case 'vrdemo':
// app.queryParams = '?roomAlias=' + encodeURIComponent(app.data.roomAlias);
// break;
// }
return app;
},
onRoomStateEvents: function(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') {
return;
}
this._updateApps();
},
_getApps: function() {
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
if (!appsStateEvents) {
return [];
}
const appsStateEvent = appsStateEvents.getContent();
if (Object.keys(appsStateEvent).length < 1) {
return [];
}
return Object.keys(appsStateEvent).map((appId) => {
return this._initAppConfig(appId, appsStateEvent[appId]);
});
},
_updateApps: function() {
const apps = this._getApps();
if (apps.length < 1) {
dis.dispatch({
action: 'appsDrawer',
show: false,
});
}
this.setState({
apps: apps,
});
},
onClickAddWidget: function(e) {
if (e) {
e.preventDefault();
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) :
null;
Modal.createDialog(IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
},
render: function() {
const apps = this.state.apps.map(
(app, index, arr) => {
return <AppTile
key={app.name}
id={app.id}
url={app.url}
name={app.name}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
/>;
});
const addWidget = this.state.apps && this.state.apps.length < 2 &&
(<div onClick={this.onClickAddWidget}
role="button"
tabIndex="0"
className="mx_AddWidget_button"
title={_t('Add a widget')}>
[+] {_t('Add a widget')}
</div>);
return (
<div className="mx_AppsDrawer">
<div id="apps" className="mx_AppsContainer">
{apps}
</div>
{addWidget}
</div>
);
},
});

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';
@ -39,26 +40,52 @@ export default class Autocomplete extends React.Component {
}; };
} }
async componentWillReceiveProps(props, state) { componentWillReceiveProps(newProps, state) {
if (props.query === this.props.query) { // Query hasn't changed so don't try to complete it
return null; if (newProps.query === this.props.query) {
}
return await this.complete(props.query, props.selection);
}
async complete(query, selection) {
let forceComplete = this.state.forceComplete;
const completionPromise = getCompletions(query, selection, forceComplete);
this.completionPromise = completionPromise;
const completions = await this.completionPromise;
// There's a newer completion request, so ignore results.
if (completionPromise !== this.completionPromise) {
return; return;
} }
const completionList = flatMap(completions, provider => provider.completions); this.complete(newProps.query, newProps.selection);
}
complete(query, selection) {
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
}
if (query === "") {
this.setState({
// Clear displayed completions
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: COMPOSER_SELECTED,
// Hide the autocomplete box
hide: true,
});
return Q(null);
}
let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200);
// Don't debounce if we are already showing completions
if (this.state.completions.length > 0) {
autocompleteDelay = 0;
}
const deferred = Q.defer();
this.debounceCompletionsRequest = setTimeout(() => {
getCompletions(
query, selection, this.state.forceComplete,
).then((completions) => {
this.processCompletions(completions);
deferred.resolve();
});
}, autocompleteDelay);
return deferred.promise;
}
processCompletions(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,21 +96,18 @@ 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)) {
@ -95,7 +119,8 @@ export default class Autocomplete extends React.Component {
completionList, completionList,
selectionOffset, selectionOffset,
hide, hide,
forceComplete, // Force complete is turned off each time since we can't edit the query in that case
forceComplete: false,
}); });
} }
@ -149,6 +174,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 +195,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 +211,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 +249,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

@ -19,7 +19,9 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import sdk from '../../../index'; import sdk from '../../../index';
import dis from "../../../dispatcher"; import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils'; import ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t, _tJsx} from '../../../languageHandler'; import { _t, _tJsx} from '../../../languageHandler';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({ module.exports = React.createClass({
@ -28,6 +30,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// js-sdk room object // js-sdk room object
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
userId: React.PropTypes.string.isRequired,
showApps: React.PropTypes.bool,
// Conference Handler implementation // Conference Handler implementation
conferenceHandler: React.PropTypes.object, conferenceHandler: React.PropTypes.object,
@ -70,10 +74,10 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var CallView = sdk.getComponent("voip.CallView"); const CallView = sdk.getComponent("voip.CallView");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
var fileDropTarget = null; let fileDropTarget = null;
if (this.props.draggingFile) { if (this.props.draggingFile) {
fileDropTarget = ( fileDropTarget = (
<div className="mx_RoomView_fileDropTarget"> <div className="mx_RoomView_fileDropTarget">
@ -87,14 +91,13 @@ module.exports = React.createClass({
); );
} }
var conferenceCallNotification = null; let conferenceCallNotification = null;
if (this.props.displayConfCallNotification) { if (this.props.displayConfCallNotification) {
let supportedText = ''; let supportedText = '';
let joinNode; let joinNode;
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
supportedText = _t(" (unsupported)"); supportedText = _t(" (unsupported)");
} } else {
else {
joinNode = (<span> joinNode = (<span>
{_tJsx( {_tJsx(
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.", "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
@ -105,7 +108,6 @@ module.exports = React.createClass({
] ]
)} )}
</span>); </span>);
} }
// XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages, // XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages,
// but there are translations for this in the languages we do have so I'm leaving it for now. // but there are translations for this in the languages we do have so I'm leaving it for now.
@ -118,7 +120,7 @@ module.exports = React.createClass({
); );
} }
var callView = ( const callView = (
<CallView ref="callView" room={this.props.room} <CallView ref="callView" room={this.props.room}
ConferenceHandler={this.props.conferenceHandler} ConferenceHandler={this.props.conferenceHandler}
onResize={this.props.onResize} onResize={this.props.onResize}
@ -126,8 +128,17 @@ module.exports = React.createClass({
/> />
); );
let appsDrawer = null;
if(UserSettingsStore.isFeatureEnabled('matrix_apps') && this.props.showApps) {
appsDrawer = <AppsDrawer ref="appsDrawer"
room={this.props.room}
userId={this.props.userId}
maxHeight={this.props.maxHeight}/>;
}
return ( return (
<div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} > <div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} >
{ appsDrawer }
{ fileDropTarget } { fileDropTarget }
{ callView } { callView }
{ conferenceCallNotification } { conferenceCallNotification }

View file

@ -13,16 +13,15 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require('react'); import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
var CallHandler = require('../../../CallHandler'); import CallHandler from '../../../CallHandler';
var MatrixClientPeg = require('../../../MatrixClientPeg'); import MatrixClientPeg from '../../../MatrixClientPeg';
var Modal = require('../../../Modal'); import Modal from '../../../Modal';
var sdk = require('../../../index'); import sdk from '../../../index';
var dis = require('../../../dispatcher'); import dis from '../../../dispatcher';
import Autocomplete from './Autocomplete'; import Autocomplete from './Autocomplete';
import classNames from 'classnames'; import classNames from 'classnames';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
@ -32,6 +31,8 @@ export default class MessageComposer extends React.Component {
this.onCallClick = this.onCallClick.bind(this); this.onCallClick = this.onCallClick.bind(this);
this.onHangupClick = this.onHangupClick.bind(this); this.onHangupClick = this.onHangupClick.bind(this);
this.onUploadClick = this.onUploadClick.bind(this); this.onUploadClick = this.onUploadClick.bind(this);
this.onShowAppsClick = this.onShowAppsClick.bind(this);
this.onHideAppsClick = this.onHideAppsClick.bind(this);
this.onUploadFileSelected = this.onUploadFileSelected.bind(this); this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.uploadFiles = this.uploadFiles.bind(this); this.uploadFiles = this.uploadFiles.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this); this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
@ -57,7 +58,6 @@ export default class MessageComposer extends React.Component {
}, },
showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false), showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
}; };
} }
componentDidMount() { componentDidMount() {
@ -127,7 +127,7 @@ export default class MessageComposer extends React.Component {
if(shouldUpload) { if(shouldUpload) {
// MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file // MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file
if (files) { if (files) {
for(var i=0; i<files.length; i++) { for(let i=0; i<files.length; i++) {
this.props.uploadFile(files[i]); this.props.uploadFile(files[i]);
} }
} }
@ -139,7 +139,7 @@ export default class MessageComposer extends React.Component {
} }
onHangupClick() { onHangupClick() {
var call = CallHandler.getCallForRoom(this.props.room.roomId); const call = CallHandler.getCallForRoom(this.props.room.roomId);
//var call = CallHandler.getAnyActiveCall(); //var call = CallHandler.getAnyActiveCall();
if (!call) { if (!call) {
return; return;
@ -152,20 +152,68 @@ export default class MessageComposer extends React.Component {
}); });
} }
// _startCallApp(isAudioConf) {
// dis.dispatch({
// action: 'appsDrawer',
// show: true,
// });
// const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
// let appsStateEvent = {};
// if (appsStateEvents) {
// appsStateEvent = appsStateEvents.getContent();
// }
// if (!appsStateEvent.videoConf) {
// appsStateEvent.videoConf = {
// type: 'jitsi',
// // FIXME -- This should not be localhost
// url: 'http://localhost:8000/jitsi.html',
// data: {
// confId: this.props.room.roomId.replace(/[^A-Za-z0-9]/g, '_') + Date.now(),
// isAudioConf: isAudioConf,
// },
// };
// MatrixClientPeg.get().sendStateEvent(
// this.props.room.roomId,
// 'im.vector.modular.widgets',
// appsStateEvent,
// '',
// ).then(() => console.log('Sent state'), (e) => console.error(e));
// }
// }
onCallClick(ev) { onCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video", type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
// this._startCallApp(false);
} }
onVoiceCallClick(ev) { onVoiceCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: 'voice', type: "voice",
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
// this._startCallApp(true);
}
onShowAppsClick(ev) {
dis.dispatch({
action: 'appsDrawer',
show: true,
});
}
onHideAppsClick(ev) {
dis.dispatch({
action: 'appsDrawer',
show: false,
});
} }
onInputContentChanged(content: string, selection: {start: number, end: number}) { onInputContentChanged(content: string, selection: {start: number, end: number}) {
@ -216,19 +264,19 @@ export default class MessageComposer extends React.Component {
} }
render() { render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); const me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'}; const uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" + const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" +
(UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old")); (UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old"));
var controls = []; const controls = [];
controls.push( controls.push(
<div key="controls_avatar" className="mx_MessageComposer_avatar"> <div key="controls_avatar" className="mx_MessageComposer_avatar">
<MemberAvatar member={me} width={24} height={24} /> <MemberAvatar member={me} width={24} height={24} />
</div> </div>,
); );
let e2eImg, e2eTitle, e2eClass; let e2eImg, e2eTitle, e2eClass;
@ -247,16 +295,15 @@ export default class MessageComposer extends React.Component {
controls.push( controls.push(
<img key="e2eIcon" className={e2eClass} src={e2eImg} width="12" height="12" <img key="e2eIcon" className={e2eClass} src={e2eImg} width="12" height="12"
alt={e2eTitle} title={e2eTitle} alt={e2eTitle} title={e2eTitle}
/> />,
); );
var callButton, videoCallButton, hangupButton; let callButton, videoCallButton, hangupButton, showAppsButton, hideAppsButton;
if (this.props.callState && this.props.callState !== 'ended') { if (this.props.callState && this.props.callState !== 'ended') {
hangupButton = hangupButton =
<div key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}> <div key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
<img src="img/hangup.svg" alt={ _t('Hangup') } title={ _t('Hangup') } width="25" height="26"/> <img src="img/hangup.svg" alt={ _t('Hangup') } title={ _t('Hangup') } width="25" height="26"/>
</div>; </div>;
} } else {
else {
callButton = callButton =
<div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={ _t('Voice call') }> <div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={ _t('Voice call') }>
<TintableSvg src="img/icon-call.svg" width="35" height="35"/> <TintableSvg src="img/icon-call.svg" width="35" height="35"/>
@ -267,14 +314,29 @@ export default class MessageComposer extends React.Component {
</div>; </div>;
} }
var canSendMessages = this.props.room.currentState.maySendMessage( // Apps
if (UserSettingsStore.isFeatureEnabled('matrix_apps')) {
if (this.props.showApps) {
hideAppsButton =
<div key="controls_hide_apps" className="mx_MessageComposer_apps" onClick={this.onHideAppsClick} title={_t("Hide Apps")}>
<TintableSvg src="img/icons-apps-active.svg" width="35" height="35"/>
</div>;
} else {
showAppsButton =
<div key="show_apps" className="mx_MessageComposer_apps" onClick={this.onShowAppsClick} title={_t("Show Apps")}>
<TintableSvg src="img/icons-apps.svg" width="35" height="35"/>
</div>;
}
}
const canSendMessages = this.props.room.currentState.maySendMessage(
MatrixClientPeg.get().credentials.userId); MatrixClientPeg.get().credentials.userId);
if (canSendMessages) { if (canSendMessages) {
// This also currently includes the call buttons. Really we should // This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly // check separately for whether we can call, but this is slightly
// complex because of conference calls. // complex because of conference calls.
var uploadButton = ( const uploadButton = (
<div key="controls_upload" className="mx_MessageComposer_upload" <div key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title={ _t('Upload file') }> onClick={this.onUploadClick} title={ _t('Upload file') }>
<TintableSvg src="img/icons-upload.svg" width="35" height="35"/> <TintableSvg src="img/icons-upload.svg" width="35" height="35"/>
@ -300,7 +362,7 @@ export default class MessageComposer extends React.Component {
controls.push( controls.push(
<MessageComposerInput <MessageComposerInput
ref={c => this.messageComposerInput = c} ref={(c) => this.messageComposerInput = c}
key="controls_input" key="controls_input"
onResize={this.props.onResize} onResize={this.props.onResize}
room={this.props.room} room={this.props.room}
@ -316,13 +378,15 @@ export default class MessageComposer extends React.Component {
uploadButton, uploadButton,
hangupButton, hangupButton,
callButton, callButton,
videoCallButton videoCallButton,
showAppsButton,
hideAppsButton,
); );
} else { } else {
controls.push( controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error"> <div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') } { _t('You do not have permission to post to this room') }
</div> </div>,
); );
} }
@ -340,7 +404,7 @@ export default class MessageComposer extends React.Component {
const {style, blockType} = this.state.inputState; const {style, blockType} = this.state.inputState;
const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map( const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map(
name => { (name) => {
const active = style.includes(name) || blockType === name; const active = style.includes(name) || blockType === name;
const suffix = active ? '-o-n' : ''; const suffix = active ? '-o-n' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
@ -403,5 +467,8 @@ MessageComposer.propTypes = {
uploadFile: React.PropTypes.func.isRequired, uploadFile: React.PropTypes.func.isRequired,
// opacity for dynamic UI fading effects // opacity for dynamic UI fading effects
opacity: React.PropTypes.number opacity: React.PropTypes.number,
// string representing the current room app drawer state
showApps: React.PropTypes.bool,
}; };

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,12 +58,42 @@ 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)) {
return 'toggle-mode'; return 'toggle-mode';
} }
// Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I
if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) {
// When null is returned, draft-js will NOT preventDefault, allowing dev tools
// to be toggled when the editor is focussed
return null;
}
return getDefaultKeyBinding(e); return getDefaultKeyBinding(e);
} }
@ -77,6 +107,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,13 +115,13 @@ 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);
this.onEscape = this.onEscape.bind(this); this.onEscape = this.onEscape.bind(this);
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this); this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
this.onTextPasted = this.onTextPasted.bind(this);
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false);
@ -103,6 +134,10 @@ export default class MessageComposerInput extends React.Component {
// the original editor state, before we started tabbing through completions // the original editor state, before we started tabbing through completions
originalEditorState: null, originalEditorState: null,
// the virtual state "above" the history stack, the message currently being composed that
// we want to persist whilst browsing history
currentlyComposedEditorState: null,
}; };
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
@ -132,110 +167,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 +185,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,7 +200,7 @@ 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());
@ -275,9 +213,9 @@ export default class MessageComposerInput extends React.Component {
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();
@ -292,13 +230,14 @@ export default class MessageComposerInput extends React.Component {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
} }
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.moveSelectionToEnd(editorState);
this.onEditorContentChanged(editorState); this.onEditorContentChanged(editorState);
editor.focus(); editor.focus();
} }
} }
break; break;
} }
} };
onTypingActivity() { onTypingActivity() {
this.isTyping = true; this.isTyping = true;
@ -318,7 +257,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 +274,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 +295,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 +306,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()) { /**
* We're overriding setState here because it's the most convenient way to monitor changes to the editorState.
* Doing it using a separate function that calls setState is a possibility (and was the old approach), but that
* 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(); this.onTypingActivity();
} else { } else {
this.onFinishedTyping(); this.onFinishedTyping();
} }
if (this.props.onContentChanged) { if (!state.hasOwnProperty('originalEditorState')) {
const textContent = editorState.getCurrentContent().getPlainText(); state.originalEditorState = null;
const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), }
editorState.getCurrentContent().getBlocksAsArray()); }
super.setState(state, () => {
if (callback != null) {
callback();
}
if (this.props.onContentChanged) {
const textContent = this.state.editorState
.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(
this.state.editorState.getSelection(),
this.state.editorState.getCurrentContent().getBlocksAsArray());
this.props.onContentChanged(textContent, selection); this.props.onContentChanged(textContent, selection);
} }
return contentChanged.promise; });
}
setEditorState(editorState: EditorState) {
return this.onEditorContentChanged(editorState, false);
} }
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 +393,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,19 +431,49 @@ export default class MessageComposerInput extends React.Component {
} }
if (newState != null) { if (newState != null) {
this.setEditorState(newState); this.setState({editorState: newState});
return true; return true;
} }
return false; return false;
} }
onTextPasted(text: string, html?: string) {
const currentSelection = this.state.editorState.getSelection();
const currentContent = this.state.editorState.getCurrentContent();
let contentState = null;
if (html) {
contentState = Modifier.replaceWithFragment(
currentContent,
currentSelection,
RichText.htmlToContentState(html).getBlockMap(),
);
} else {
contentState = Modifier.replaceText(currentContent, currentSelection, text);
}
let newEditorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
newEditorState = EditorState.forceSelection(newEditorState, contentState.getSelectionAfter());
this.onEditorContentChanged(newEditorState);
return true;
}
handleReturn(ev) { handleReturn(ev) {
if (ev.shiftKey) { if (ev.shiftKey) {
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
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 +482,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 +494,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,
@ -520,9 +512,30 @@ export default class MessageComposerInput extends React.Component {
} }
if (this.state.isRichtextEnabled) { if (this.state.isRichtextEnabled) {
contentHTML = HtmlUtils.stripParagraphs( // We should only send HTML if any block is styled or contains inline style
RichText.contentStateToHTML(contentState) let shouldSendHTML = false;
const blocks = contentState.getBlocksAsArray();
if (blocks.some((block) => block.getType() !== 'unstyled')) {
shouldSendHTML = true;
} else {
const characterLists = blocks.map((block) => block.getCharacterList());
// For each block of characters, determine if any inline styles are applied
// and if yes, send HTML
characterLists.forEach((characters) => {
const numberOfStylesForCharacters = characters.map(
(character) => character.getStyle().toArray().length,
).toArray();
// If any character has more than 0 inline styles applied, send HTML
if (numberOfStylesForCharacters.some((styles) => styles > 0)) {
shouldSendHTML = true;
}
});
}
if (shouldSendHTML) {
contentHTML = HtmlUtils.processHtmlForSending(
RichText.contentStateToHTML(contentState),
); );
}
} else { } else {
const md = new Markdown(contentText); const md = new Markdown(contentText);
if (md.isPlainText()) { if (md.isPlainText()) {
@ -543,12 +556,20 @@ 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? if (this.state.isRichtextEnabled) {
this.sentHistory.push(contentHTML || contentText); this.historyManager.addItem(
contentHTML ? contentHTML : contentText,
contentHTML ? 'html' : 'markdown',
);
} else {
// Always store MD input as input history
this.historyManager.addItem(contentText, '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);
@ -569,71 +590,149 @@ export default class MessageComposerInput extends React.Component {
return true; return true;
} }
async onUpArrow(e) { onUpArrow = (e) => {
const completion = this.autocomplete.onUpArrow(); this.onVerticalArrow(e, true);
if (completion != null) { };
e.preventDefault();
} onDownArrow = (e) => {
return await this.setDisplayedCompletion(completion); this.onVerticalArrow(e, false);
};
onVerticalArrow = (e, up) => {
// Select history only if we are not currently auto-completing
if (this.autocomplete.state.completionList.length === 0) {
// Don't go back in history if we're in the middle of a multi-line message
const selection = this.state.editorState.getSelection();
const blockKey = selection.getStartKey();
const firstBlock = this.state.editorState.getCurrentContent().getFirstBlock();
const lastBlock = this.state.editorState.getCurrentContent().getLastBlock();
const selectionOffset = selection.getAnchorOffset();
let canMoveUp = false;
let canMoveDown = false;
if (blockKey === firstBlock.getKey()) {
const textBeforeCursor = firstBlock.getText().slice(0, selectionOffset);
canMoveUp = textBeforeCursor.indexOf('\n') === -1;
} }
async onDownArrow(e) { if (blockKey === lastBlock.getKey()) {
const completion = this.autocomplete.onDownArrow(); const textAfterCursor = lastBlock.getText().slice(selectionOffset);
e.preventDefault(); canMoveDown = textAfterCursor.indexOf('\n') === -1;
return await this.setDisplayedCompletion(completion);
} }
// tab and shift-tab are mapped to down and up arrow respectively if ((up && !canMoveUp) || (!up && !canMoveDown)) return;
async onTab(e) {
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes const selected = this.selectHistory(up);
const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); if (selected) {
if (!didTab && this.autocomplete) { // We're selecting history, so prevent the key event from doing anything else
this.autocomplete.forceComplete().then(() => { e.preventDefault();
this.onDownArrow(e); }
} else {
this.moveAutocompleteSelection(up);
}
};
selectHistory = async (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.historyManager.currentIndex === this.historyManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return;
}
this.setState({
currentlyComposedEditorState: this.state.editorState,
}); });
} } else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
// True when we return to the message being composed currently
this.setState({
editorState: this.state.currentlyComposedEditorState,
});
this.historyManager.currentIndex = this.historyManager.history.length;
return;
} }
onEscape(e) { const newContent = this.historyManager.getItem(delta, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
let editorState = EditorState.push(
this.state.editorState,
newContent,
'insert-characters',
);
// Move selection to the end of the selected history
let newSelection = SelectionState.createEmpty(newContent.getLastBlock().getKey());
newSelection = newSelection.merge({
focusOffset: newContent.getLastBlock().getLength(),
anchorOffset: newContent.getLastBlock().getLength(),
});
editorState = EditorState.forceSelection(editorState, newSelection);
this.setState({editorState});
return true;
};
onTab = async (e) => {
e.preventDefault();
if (this.autocomplete.state.completionList.length === 0) {
// Force completions to show for the text currently entered
await this.autocomplete.forceComplete();
// Select the first item by moving "down"
await this.moveAutocompleteSelection(false);
} else {
await this.moveAutocompleteSelection(e.shiftKey);
}
};
moveAutocompleteSelection = (up) => {
const completion = up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
return this.setDisplayedCompletion(completion);
};
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!
@ -658,8 +757,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 +777,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;
@ -713,7 +812,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"
@ -729,13 +828,14 @@ export default class MessageComposerInput extends React.Component {
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
handlePastedText={this.onTextPasted}
handlePastedFiles={this.props.onFilesPasted} handlePastedFiles={this.props.onFilesPasted}
stripPastedStyles={!this.state.isRichtextEnabled} stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab} onTab={this.onTab}
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

@ -1,124 +1,125 @@
{ {
"af":"Afrikaans", "Add a widget": "Add a widget",
"ar-ae":"Arabic (U.A.E.)", "af": "Afrikaans",
"ar-bh":"Arabic (Bahrain)", "ar-ae": "Arabic (U.A.E.)",
"ar-dz":"Arabic (Algeria)", "ar-bh": "Arabic (Bahrain)",
"ar-eg":"Arabic (Egypt)", "ar-dz": "Arabic (Algeria)",
"ar-iq":"Arabic (Iraq)", "ar-eg": "Arabic (Egypt)",
"ar-jo":"Arabic (Jordan)", "ar-iq": "Arabic (Iraq)",
"ar-kw":"Arabic (Kuwait)", "ar-jo": "Arabic (Jordan)",
"ar-lb":"Arabic (Lebanon)", "ar-kw": "Arabic (Kuwait)",
"ar-ly":"Arabic (Libya)", "ar-lb": "Arabic (Lebanon)",
"ar-ma":"Arabic (Morocco)", "ar-ly": "Arabic (Libya)",
"ar-om":"Arabic (Oman)", "ar-ma": "Arabic (Morocco)",
"ar-qa":"Arabic (Qatar)", "ar-om": "Arabic (Oman)",
"ar-sa":"Arabic (Saudi Arabia)", "ar-qa": "Arabic (Qatar)",
"ar-sy":"Arabic (Syria)", "ar-sa": "Arabic (Saudi Arabia)",
"ar-tn":"Arabic (Tunisia)", "ar-sy": "Arabic (Syria)",
"ar-ye":"Arabic (Yemen)", "ar-tn": "Arabic (Tunisia)",
"be":"Belarusian", "ar-ye": "Arabic (Yemen)",
"bg":"Bulgarian", "be": "Belarusian",
"ca":"Catalan", "bg": "Bulgarian",
"cs":"Czech", "ca": "Catalan",
"da":"Danish", "cs": "Czech",
"de-at":"German (Austria)", "da": "Danish",
"de-ch":"German (Switzerland)", "de-at": "German (Austria)",
"de":"German", "de-ch": "German (Switzerland)",
"de-li":"German (Liechtenstein)", "de": "German",
"de-lu":"German (Luxembourg)", "de-li": "German (Liechtenstein)",
"el":"Greek", "de-lu": "German (Luxembourg)",
"en-au":"English (Australia)", "el": "Greek",
"en-bz":"English (Belize)", "en-au": "English (Australia)",
"en-ca":"English (Canada)", "en-bz": "English (Belize)",
"en":"English", "en-ca": "English (Canada)",
"en-gb":"English (United Kingdom)", "en": "English",
"en-ie":"English (Ireland)", "en-gb": "English (United Kingdom)",
"en-jm":"English (Jamaica)", "en-ie": "English (Ireland)",
"en-nz":"English (New Zealand)", "en-jm": "English (Jamaica)",
"en-tt":"English (Trinidad)", "en-nz": "English (New Zealand)",
"en-us":"English (United States)", "en-tt": "English (Trinidad)",
"en-za":"English (South Africa)", "en-us": "English (United States)",
"es-ar":"Spanish (Argentina)", "en-za": "English (South Africa)",
"es-bo":"Spanish (Bolivia)", "es-ar": "Spanish (Argentina)",
"es-cl":"Spanish (Chile)", "es-bo": "Spanish (Bolivia)",
"es-co":"Spanish (Colombia)", "es-cl": "Spanish (Chile)",
"es-cr":"Spanish (Costa Rica)", "es-co": "Spanish (Colombia)",
"es-do":"Spanish (Dominican Republic)", "es-cr": "Spanish (Costa Rica)",
"es-ec":"Spanish (Ecuador)", "es-do": "Spanish (Dominican Republic)",
"es-gt":"Spanish (Guatemala)", "es-ec": "Spanish (Ecuador)",
"es-hn":"Spanish (Honduras)", "es-gt": "Spanish (Guatemala)",
"es-mx":"Spanish (Mexico)", "es-hn": "Spanish (Honduras)",
"es-ni":"Spanish (Nicaragua)", "es-mx": "Spanish (Mexico)",
"es-pa":"Spanish (Panama)", "es-ni": "Spanish (Nicaragua)",
"es-pe":"Spanish (Peru)", "es-pa": "Spanish (Panama)",
"es-pr":"Spanish (Puerto Rico)", "es-pe": "Spanish (Peru)",
"es-py":"Spanish (Paraguay)", "es-pr": "Spanish (Puerto Rico)",
"es":"Spanish (Spain)", "es-py": "Spanish (Paraguay)",
"es-sv":"Spanish (El Salvador)", "es": "Spanish (Spain)",
"es-uy":"Spanish (Uruguay)", "es-sv": "Spanish (El Salvador)",
"es-ve":"Spanish (Venezuela)", "es-uy": "Spanish (Uruguay)",
"et":"Estonian", "es-ve": "Spanish (Venezuela)",
"eu":"Basque (Basque)", "et": "Estonian",
"fa":"Farsi", "eu": "Basque (Basque)",
"fi":"Finnish", "fa": "Farsi",
"fo":"Faeroese", "fi": "Finnish",
"fr-be":"French (Belgium)", "fo": "Faeroese",
"fr-ca":"French (Canada)", "fr-be": "French (Belgium)",
"fr-ch":"French (Switzerland)", "fr-ca": "French (Canada)",
"fr":"French", "fr-ch": "French (Switzerland)",
"fr-lu":"French (Luxembourg)", "fr": "French",
"ga":"Irish", "fr-lu": "French (Luxembourg)",
"gd":"Gaelic (Scotland)", "ga": "Irish",
"he":"Hebrew", "gd": "Gaelic (Scotland)",
"hi":"Hindi", "he": "Hebrew",
"hr":"Croatian", "hi": "Hindi",
"hu":"Hungarian", "hr": "Croatian",
"id":"Indonesian", "hu": "Hungarian",
"is":"Icelandic", "id": "Indonesian",
"it-ch":"Italian (Switzerland)", "is": "Icelandic",
"it":"Italian", "it-ch": "Italian (Switzerland)",
"ja":"Japanese", "it": "Italian",
"ji":"Yiddish", "ja": "Japanese",
"ko":"Korean", "ji": "Yiddish",
"lt":"Lithuanian", "ko": "Korean",
"lv":"Latvian", "lt": "Lithuanian",
"mk":"Macedonian (FYROM)", "lv": "Latvian",
"ms":"Malaysian", "mk": "Macedonian (FYROM)",
"mt":"Maltese", "ms": "Malaysian",
"nl-be":"Dutch (Belgium)", "mt": "Maltese",
"nl":"Dutch", "nl-be": "Dutch (Belgium)",
"no":"Norwegian", "nl": "Dutch",
"pl":"Polish", "no": "Norwegian",
"pt-br":"Brazilian Portuguese", "pl": "Polish",
"pt":"Portuguese", "pt-br": "Brazilian Portuguese",
"rm":"Rhaeto-Romanic", "pt": "Portuguese",
"ro-mo":"Romanian (Republic of Moldova)", "rm": "Rhaeto-Romanic",
"ro":"Romanian", "ro-mo": "Romanian (Republic of Moldova)",
"ru-mo":"Russian (Republic of Moldova)", "ro": "Romanian",
"ru":"Russian", "ru-mo": "Russian (Republic of Moldova)",
"sb":"Sorbian", "ru": "Russian",
"sk":"Slovak", "sb": "Sorbian",
"sl":"Slovenian", "sk": "Slovak",
"sq":"Albanian", "sl": "Slovenian",
"sr":"Serbian", "sq": "Albanian",
"sv-fi":"Swedish (Finland)", "sr": "Serbian",
"sv":"Swedish", "sv-fi": "Swedish (Finland)",
"sx":"Sutu", "sv": "Swedish",
"sz":"Sami (Lappish)", "sx": "Sutu",
"th":"Thai", "sz": "Sami (Lappish)",
"tn":"Tswana", "th": "Thai",
"tr":"Turkish", "tn": "Tswana",
"ts":"Tsonga", "tr": "Turkish",
"uk":"Ukrainian", "ts": "Tsonga",
"ur":"Urdu", "uk": "Ukrainian",
"ve":"Venda", "ur": "Urdu",
"vi":"Vietnamese", "ve": "Venda",
"xh":"Xhosa", "vi": "Vietnamese",
"zh-cn":"Chinese (PRC)", "xh": "Xhosa",
"zh-hk":"Chinese (Hong Kong SAR)", "zh-cn": "Chinese (PRC)",
"zh-sg":"Chinese (Singapore)", "zh-hk": "Chinese (Hong Kong SAR)",
"zh-tw":"Chinese (Taiwan)", "zh-sg": "Chinese (Singapore)",
"zu":"Zulu", "zh-tw": "Chinese (Taiwan)",
"zu": "Zulu",
"a room": "a room", "a room": "a room",
"A text message has been sent to +%(msisdn)s. Please enter the verification code it contains": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains",
"Accept": "Accept", "Accept": "Accept",
@ -187,7 +188,6 @@
"Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.",
"Can't load user settings": "Can't load user settings", "Can't load user settings": "Can't load user settings",
"Change Password": "Change Password", "Change Password": "Change Password",
"Change colourscheme of current room": "Change colourscheme of current room",
"%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.": "%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.", "%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.": "%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.",
"%(senderName)s changed their profile picture.": "%(senderName)s changed their profile picture.", "%(senderName)s changed their profile picture.": "%(senderName)s changed their profile picture.",
"%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s changed the power level of %(powerLevelDiffText)s.", "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s changed the power level of %(powerLevelDiffText)s.",
@ -241,6 +241,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:",
@ -268,6 +269,7 @@
"Email address (optional)": "Email address (optional)", "Email address (optional)": "Email address (optional)",
"Email, name or matrix ID": "Email, name or matrix ID", "Email, name or matrix ID": "Email, name or matrix ID",
"Emoji": "Emoji", "Emoji": "Emoji",
"Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting",
"Enable encryption": "Enable encryption", "Enable encryption": "Enable encryption",
"Enable Notifications": "Enable Notifications", "Enable Notifications": "Enable Notifications",
"enabled": "enabled", "enabled": "enabled",
@ -336,6 +338,7 @@
"Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.", "Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.",
"had": "had", "had": "had",
"Hangup": "Hangup", "Hangup": "Hangup",
"Hide Apps": "Hide Apps",
"Hide read receipts": "Hide read receipts", "Hide read receipts": "Hide read receipts",
"Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar", "Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar",
"Historical": "Historical", "Historical": "Historical",
@ -377,7 +380,6 @@
"Labs": "Labs", "Labs": "Labs",
"Last seen": "Last seen", "Last seen": "Last seen",
"Leave room": "Leave room", "Leave room": "Leave room",
"Leaves room with given alias": "Leaves room with given alias",
"left and rejoined": "left and rejoined", "left and rejoined": "left and rejoined",
"left": "left", "left": "left",
"%(targetName)s left the room.": "%(targetName)s left the room.", "%(targetName)s left the room.": "%(targetName)s left the room.",
@ -393,6 +395,7 @@
"Markdown is disabled": "Markdown is disabled", "Markdown is disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled", "Markdown is enabled": "Markdown is enabled",
"matrix-react-sdk version:": "matrix-react-sdk version:", "matrix-react-sdk version:": "matrix-react-sdk version:",
"Matrix Apps": "Matrix Apps",
"Members only": "Members only", "Members only": "Members only",
"Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present", "Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present",
"Missing room_id in request": "Missing room_id in request", "Missing room_id in request": "Missing room_id in request",
@ -509,7 +512,7 @@
"%(senderName)s set their display name to %(displayName)s.": "%(senderName)s set their display name to %(displayName)s.", "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s set their display name to %(displayName)s.",
"Set": "Set", "Set": "Set",
"Settings": "Settings", "Settings": "Settings",
"Sets the room topic": "Sets the room topic", "Show Apps": "Show Apps",
"Show panel": "Show panel", "Show panel": "Show panel",
"Show Text Formatting Toolbar": "Show Text Formatting Toolbar", "Show Text Formatting Toolbar": "Show Text Formatting Toolbar",
"Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)", "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)",
@ -580,6 +583,7 @@
"Turn Markdown on": "Turn Markdown on", "Turn Markdown on": "Turn Markdown on",
"%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).": "%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).", "%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).": "%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).",
"Unable to add email address": "Unable to add email address", "Unable to add email address": "Unable to add email address",
"Unable to create widget.": "Unable to create widget.",
"Unable to remove contact information": "Unable to remove contact information", "Unable to remove contact information": "Unable to remove contact information",
"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.",
@ -744,10 +748,10 @@
"italic": "italic", "italic": "italic",
"strike": "strike", "strike": "strike",
"underline": "underline", "underline": "underline",
"code":"code", "code": "code",
"quote":"quote", "quote": "quote",
"bullet":"bullet", "bullet": "bullet",
"numbullet":"numbullet", "numbullet": "numbullet",
"%(severalUsers)sjoined %(repeats)s times": "%(severalUsers)sjoined %(repeats)s times", "%(severalUsers)sjoined %(repeats)s times": "%(severalUsers)sjoined %(repeats)s times",
"%(oneUser)sjoined %(repeats)s times": "%(oneUser)sjoined %(repeats)s times", "%(oneUser)sjoined %(repeats)s times": "%(oneUser)sjoined %(repeats)s times",
"%(severalUsers)sjoined": "%(severalUsers)sjoined", "%(severalUsers)sjoined": "%(severalUsers)sjoined",
@ -807,7 +811,6 @@
"Analytics": "Analytics", "Analytics": "Analytics",
"Opt out of analytics": "Opt out of analytics", "Opt out of analytics": "Opt out of analytics",
"Options": "Options", "Options": "Options",
"Ops user with given id": "Ops user with given id",
"Riot collects anonymous analytics to allow us to improve the application.": "Riot collects anonymous analytics to allow us to improve the application.", "Riot collects anonymous analytics to allow us to improve the application.": "Riot collects anonymous analytics to allow us to improve the application.",
"Passphrases must match": "Passphrases must match", "Passphrases must match": "Passphrases must match",
"Passphrase must not be empty": "Passphrase must not be empty", "Passphrase must not be empty": "Passphrase must not be empty",
@ -840,7 +843,6 @@
"If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.": "If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.", "If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.": "If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.",
"In future this verification process will be more sophisticated.": "In future this verification process will be more sophisticated.", "In future this verification process will be more sophisticated.": "In future this verification process will be more sophisticated.",
"Verify device": "Verify device", "Verify device": "Verify device",
"Verify a user, device, and pubkey tuple": "Verify a user, device, and pubkey tuple",
"I verify that the keys match": "I verify that the keys match", "I verify that the keys match": "I verify that the keys match",
"We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.": "We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.", "We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.": "We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.",
"Unable to restore session": "Unable to restore session", "Unable to restore session": "Unable to restore session",
@ -920,11 +922,12 @@
"Do you want to set an email address?": "Do you want to set an email address?", "Do you want to set an email address?": "Do you want to set an email address?",
"This will allow you to reset your password and receive notifications.": "This will allow you to reset your password and receive notifications.", "This will allow you to reset your password and receive notifications.": "This will allow you to reset your password and receive notifications.",
"To return to your account in future you need to set a password": "To return to your account in future you need to set a password", "To return to your account in future you need to set a password": "To return to your account in future you need to set a password",
"Skip":"Skip", "Skip": "Skip",
"Start verification": "Start verification", "Start verification": "Start verification",
"Share without verifying": "Share without verifying", "Share without verifying": "Share without verifying",
"Ignore request": "Ignore request", "Ignore request": "Ignore request",
"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):"
} }

View file

@ -1,4 +1,5 @@
{ {
"Add a widget": "Add a widget",
"af": "Afrikaans", "af": "Afrikaans",
"ar-ae": "Arabic (U.A.E.)", "ar-ae": "Arabic (U.A.E.)",
"ar-bh": "Arabic (Bahrain)", "ar-bh": "Arabic (Bahrain)",
@ -311,6 +312,7 @@
"Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.", "Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.",
"had": "had", "had": "had",
"Hangup": "Hangup", "Hangup": "Hangup",
"Hide Apps": "Hide Apps",
"Hide read receipts": "Hide read receipts", "Hide read receipts": "Hide read receipts",
"Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar", "Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar",
"Historical": "Historical", "Historical": "Historical",
@ -362,6 +364,7 @@
"Markdown is disabled": "Markdown is disabled", "Markdown is disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled", "Markdown is enabled": "Markdown is enabled",
"matrix-react-sdk version:": "matrix-react-sdk version:", "matrix-react-sdk version:": "matrix-react-sdk version:",
"Matrix Apps": "Matrix Apps",
"Members only": "Members only", "Members only": "Members only",
"Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present", "Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present",
"Missing room_id in request": "Missing room_id in request", "Missing room_id in request": "Missing room_id in request",
@ -464,6 +467,7 @@
"%(senderName)s set a profile picture.": "%(senderName)s set a profile picture.", "%(senderName)s set a profile picture.": "%(senderName)s set a profile picture.",
"%(senderName)s set their display name to %(displayName)s.": "%(senderName)s set their display name to %(displayName)s.", "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s set their display name to %(displayName)s.",
"Settings": "Settings", "Settings": "Settings",
"Show Apps": "Show Apps",
"Show panel": "Show panel", "Show panel": "Show panel",
"Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)", "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)",
"Signed Out": "Signed Out", "Signed Out": "Signed Out",

1
src/stripped-emoji.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -104,11 +104,12 @@ describe('MessageComposerInput', () => {
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 when rich text is enabled', () => {
const spy = sinon.spy(client, 'sendTextMessage'); const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(true); mci.enableRichtext(true);
addTextToDraft('☹'); addTextToDraft('☹');
@ -117,7 +118,7 @@ describe('MessageComposerInput', () => {
expect(spy.calledOnce).toEqual(true, 'should send message'); expect(spy.calledOnce).toEqual(true, 'should send message');
}); });
it('should send emoji messages in Markdown', () => { it('should send emoji messages when Markdown is enabled', () => {
const spy = sinon.spy(client, 'sendTextMessage'); const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false); mci.enableRichtext(false);
addTextToDraft('☹'); addTextToDraft('☹');