Merge remote-tracking branch 'origin/develop' into dbkr/widget_echo

This commit is contained in:
David Baker 2018-07-18 09:35:57 +01:00
commit 7aab6fa6f1
53 changed files with 1685 additions and 1247 deletions

View file

@ -95,6 +95,7 @@ module.exports = {
"new-cap": ["warn"],
"key-spacing": ["warn"],
"prefer-const": ["warn"],
"arrow-parens": "off",
// crashes currently: https://github.com/eslint/eslint/issues/6274
"generator-star-spacing": "off",

88
docs/slate-formats.md Normal file
View file

@ -0,0 +1,88 @@
Guide to data types used by the Slate-based Rich Text Editor
------------------------------------------------------------
We always store the Slate editor state in its Value form.
The schema for the Value is the same whether the editor is in MD or rich text mode, and is currently (rather arbitrarily)
dictated by the schema expected by slate-md-serializer, simply because it was the only bit of the pipeline which
has opinions on the schema. (slate-html-serializer lets you define how to serialize whatever schema you like).
The BLOCK_TAGS and MARK_TAGS give the mapping from HTML tags to the schema's node types (for blocks, which describe
block content like divs, and marks, which describe inline formatted sections like spans).
We use <p/> as the parent tag for the message (XXX: although some tags are technically not allowed to be nested within p's)
Various conversions are performed as content is moved between HTML, MD, and plaintext representations of HTML and MD.
The primitives used are:
* Markdown.js - models commonmark-formatted MD strings (as entered by the composer in MD mode)
* toHtml() - renders them to HTML suitable for sending on the wire
* isPlainText() - checks whether the parsed MD contains anything other than simple text.
* toPlainText() - renders MD to plain text in order to remove backslashes. Only works if the MD is already plaintext (otherwise it just emits HTML)
* slate-html-serializer
* converts Values to HTML (serialising) using our schema rules
* converts HTML to Values (deserialising) using our schema rules
* slate-md-serializer
* converts rich Values to MD strings (serialising) but using a non-commonmark generic MD dialect.
* This should use commonmark, but we use the serializer here for expedience rather than writing a commonmark one.
* slate-plain-serializer
* converts Values to plain text strings (serialising them) by concatenating the strings together
* converts Values from plain text strings (deserialiasing them).
* Used to initialise the editor by deserializing "" into a Value. Apparently this is the idiomatic way to initialise a blank editor.
* Used (as a bodge) to turn a rich text editor into a MD editor, when deserialising the converted MD string of the editor into a value
* PlainWithPillsSerializer
* A fork of slate-plain-serializer which is aware of Pills (hence the name) and Emoji.
* It can be configured to output Pills as:
* "plain": Pills are rendered via their 'completion' text - e.g. 'Matthew'; used for sending messages)
* "md": Pills are rendered as MD, e.g. [Matthew](https://matrix.to/#/@matthew:matrix.org) )
* "id": Pills are rendered as IDs, e.g. '@matthew:matrix.org' (used for authoring / commands)
* Emoji nodes are converted to inline utf8 emoji.
The actual conversion transitions are:
* Quoting:
* The message being quoted is taken as HTML
* ...and deserialised into a Value
* ...and then serialised into MD via slate-md-serializer if the editor is in MD mode
* Roundtripping between MD and rich text editor mode
* From MD to richtext (mdToRichEditorState):
* Serialise the MD-format Value to a MD string (converting pills to MD) with PlainWithPillsSerializer in 'md' mode
* Convert that MD string to HTML via Markdown.js
* Deserialise that Value to HTML via slate-html-serializer
* From richtext to MD (richToMdEditorState):
* Serialise the richtext-format Value to a MD string with slate-md-serializer (XXX: this should use commonmark)
* Deserialise that to a plain text value via slate-plain-serializer
* Loading history in one format into an editor which is in the other format
* Uses the same functions as for roundtripping
* Scanning the editor for a slash command
* If the editor is a single line node starting with /, then serialize it to a string with PlainWithPillsSerializer in 'id' mode
So that pills get converted to IDs suitable for commands being passed around
* Sending messages
* In RT mode:
* If there is rich content, serialize the RT-format Value to HTML body via slate-html-serializer
* Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode
* In MD mode:
* Serialize the MD-format Value into an MD string with PlainWithPillsSerializer in 'md' mode
* Parse the string with Markdown.js
* If it contains no formatting:
* Send as plaintext (as taken from Markdown.toPlainText())
* Otherwise
* Send as HTML (as taken from Markdown.toHtml())
* Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode
* Pasting HTML
* Deserialize HTML to a RT Value via slate-html-serializer
* In RT mode, insert it straight into the editor as a fragment
* In MD mode, serialise it to an MD string via slate-md-serializer and then insert the string into the editor as a fragment.
The various scenarios and transitions could be drawn into a pretty diagram if one felt the urge, but hopefully the above
gives sufficient detail on how it's all meant to work.

View file

@ -59,9 +59,6 @@
"classnames": "^2.1.2",
"commonmark": "^0.28.1",
"counterpart": "^0.18.0",
"draft-js": "^0.11.0-alpha",
"draft-js-export-html": "^0.6.0",
"draft-js-export-markdown": "^0.3.0",
"emojione": "2.2.7",
"file-saver": "^1.3.3",
"filesize": "3.5.6",
@ -87,6 +84,11 @@
"react-beautiful-dnd": "^4.0.1",
"react-dom": "^15.6.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"resize-observer-polyfill": "^1.5.0",
"slate": "0.33.4",
"slate-react": "^0.12.4",
"slate-html-serializer": "^0.6.1",
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",
"sanitize-html": "^1.14.1",
"text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0",

View file

@ -291,6 +291,10 @@ textarea {
vertical-align: middle;
}
.mx_emojione_selected {
background-color: $accent-color;
}
::-moz-selection {
background-color: $accent-color;
color: $selection-fg-color;

View file

@ -54,6 +54,10 @@ limitations under the License.
}
.mx_LeftPanel .mx_AppTileFullWidth {
height: 132px;
}
.mx_LeftPanel .mx_RoomList_scrollbar {
order: 1;

View file

@ -176,10 +176,7 @@ hr.mx_RoomView_myReadMarker {
z-index: 1000;
overflow: hidden;
-webkit-transition: all .2s ease-out;
-moz-transition: all .2s ease-out;
-ms-transition: all .2s ease-out;
-o-transition: all .2s ease-out;
transition: all .2s ease-out;
}
.mx_RoomView_statusArea_expanded {

View file

@ -27,6 +27,10 @@
padding-right: 5px;
}
.mx_UserPill_selected {
background-color: $accent-color ! important;
}
.mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me,
.mx_EventTile_content .mx_AtRoomPill,
.mx_MessageComposer_input .mx_AtRoomPill {

View file

@ -126,6 +126,12 @@ limitations under the License.
overflow: hidden;
}
.mx_AppTileBody_mini {
height: 132px;
width: 100%;
overflow: hidden;
}
.mx_AppTileBody iframe {
width: 100%;
height: 280px;

View file

@ -186,7 +186,6 @@ limitations under the License.
.mx_EventTile_msgOption {
float: right;
text-align: right;
z-index: 1;
position: relative;
width: 90px;
@ -448,6 +447,7 @@ limitations under the License.
.mx_EventTile_content .markdown-body h2
{
font-size: 1.5em;
border-bottom: none ! important; // override GFM
}
.mx_EventTile_content .markdown-body a {

View file

@ -79,12 +79,29 @@ limitations under the License.
display: flex;
flex-direction: column;
min-height: 60px;
justify-content: center;
justify-content: start;
align-items: flex-start;
font-size: 14px;
margin-right: 6px;
}
.mx_MessageComposer_editor {
width: 100%;
max-height: 120px;
min-height: 19px;
overflow: auto;
word-break: break-word;
}
// FIXME: rather unpleasant hack to get rid of <p/> margins.
// really we should be mixing in markdown-body from gfm.css instead
.mx_MessageComposer_editor > :first-child {
margin-top: 0 ! important;
}
.mx_MessageComposer_editor > :last-child {
margin-bottom: 0 ! important;
}
@keyframes visualbell
{
from { background-color: #faa }
@ -95,28 +112,6 @@ limitations under the License.
animation: 0.2s visualbell;
}
.mx_MessageComposer_input_empty .public-DraftEditorPlaceholder-root {
display: none;
}
.mx_MessageComposer_input .DraftEditor-root {
width: 100%;
flex: 1;
word-break: break-word;
max-height: 120px;
min-height: 21px;
overflow: auto;
}
.mx_MessageComposer_input .DraftEditor-root .DraftEditor-editorContainer {
/* Ensure mx_UserPill and mx_RoomPill (see _RichText) are not obscured from the top */
padding-top: 2px;
}
.mx_MessageComposer .public-DraftStyleDefault-block {
overflow-x: hidden;
}
.mx_MessageComposer_input blockquote {
color: $blockquote-fg-color;
margin: 0 0 16px;
@ -124,7 +119,7 @@ limitations under the License.
border-left: 4px solid $blockquote-bar-color;
}
.mx_MessageComposer_input pre.public-DraftStyleDefault-pre pre {
.mx_MessageComposer_input pre {
background-color: $rte-code-bg-color;
border-radius: 3px;
padding: 10px;

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -63,6 +63,7 @@ import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import SettingsStore from "./settings/SettingsStore";
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import ScalarAuthClient from './ScalarAuthClient';
global.mxCalls = {
//room_id: MatrixCall
@ -402,10 +403,26 @@ function _onAction(payload) {
}
}
function _startCallApp(roomId, type) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
console.error("Attempted to start conference call widget in unknown room: " + roomId);
async function _startCallApp(roomId, type) {
// check for a working intgrations manager. Technically we could put
// the state event in anyway, but the resulting widget would then not
// work for us. Better that the user knows before everyone else in the
// room sees it.
const scalarClient = new ScalarAuthClient();
let haveScalar = false;
try {
await scalarClient.connect();
haveScalar = scalarClient.hasCredentials();
} catch (e) {
// fall through
}
if (!haveScalar) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, {
title: _t('Could not connect to the integration server'),
description: _t('A conference call could not be started because the intgrations server is not available'),
});
return;
}
@ -414,6 +431,7 @@ function _startCallApp(roomId, type) {
show: true,
});
const room = MatrixClientPeg.get().getRoom(roomId);
const currentRoomWidgets = WidgetUtils.getRoomWidgets(room);
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, 'jitsi')) {
@ -474,6 +492,14 @@ function _startCallApp(roomId, type) {
WidgetUtils.setRoomWidget(roomId, widgetId, 'jitsi', widgetUrl, 'Jitsi', widgetData).then(() => {
console.log('Jitsi widget added');
}).catch((e) => {
if (e.errcode === 'M_FORBIDDEN') {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Permission Required'),
description: _t("You do not have permission to start a conference call in this room"),
});
}
console.error(e);
});
}

View file

@ -15,46 +15,44 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ContentState, convertToRaw, convertFromRaw} from 'draft-js';
import * as RichText from './RichText';
import Markdown from './Markdown';
import { Value } from 'slate';
import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown';
type MessageFormat = 'rich' | 'markdown';
class HistoryItem {
// Keeping message for backwards-compatibility
message: string;
rawContentState: RawDraftContentState;
format: MessageFormat = 'html';
// We store history items in their native format to ensure history is accurate
// and then convert them if our RTE has subsequently changed format.
value: Value;
format: MessageFormat = 'rich';
constructor(contentState: ?ContentState, format: ?MessageFormat) {
this.rawContentState = contentState ? convertToRaw(contentState) : null;
constructor(value: ?Value, format: ?MessageFormat) {
this.value = value;
this.format = format;
}
toContentState(outputFormat: MessageFormat): ContentState {
const contentState = convertFromRaw(this.rawContentState);
if (outputFormat === 'markdown') {
if (this.format === 'html') {
return ContentState.createFromText(RichText.stateToMarkdown(contentState));
static fromJSON(obj: Object): HistoryItem {
return new HistoryItem(
Value.fromJSON(obj.value),
obj.format,
);
}
} else {
if (this.format === 'markdown') {
return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML());
}
}
// history item has format === outputFormat
return contentState;
toJSON(): Object {
return {
value: this.value.toJSON(),
format: this.format,
};
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0;
currentIndex: number = 0;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
@ -62,23 +60,28 @@ export default class ComposerHistoryManager {
// TODO: Performance issues?
let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
try {
this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)),
HistoryItem.fromJSON(JSON.parse(item)),
);
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
}
this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
}
save(contentState: ContentState, format: MessageFormat) {
const item = new HistoryItem(contentState, format);
save(value: Value, format: MessageFormat) {
const item = new HistoryItem(value, format);
this.history.push(item);
this.currentIndex = this.lastIndex + 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item));
this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
}
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;
getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

View file

@ -112,41 +112,6 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
/>;
}
export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = html;
if (contentDiv.children.length === 0) {
return contentDiv.innerHTML;
}
let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
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 {
const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true));
contentHTML += temp.innerHTML;
}
}
return contentHTML;
}
/*
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
@ -418,10 +383,13 @@ class TextHighlighter extends BaseHighlighter {
* opts.highlightLink: optional href to add to highlighted words
* opts.disableBigEmoji: optional argument to disable the big emoji class.
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
* opts.returnString: return an HTML string rather than JSX elements
* opts.emojiOne: optional param to do emojiOne (default true)
*/
export function bodyToHtml(content, highlights, opts={}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne;
let bodyHasEmoji = false;
let strippedBody;
@ -447,8 +415,9 @@ export function bodyToHtml(content, highlights, opts={}) {
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body;
if (doEmojiOne) {
bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body);
}
// Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtmlMessage) {
@ -473,6 +442,10 @@ export function bodyToHtml(content, highlights, opts={}) {
delete sanitizeHtmlParams.textFilter;
}
if (opts.returnString) {
return isDisplayedWithHtml ? safeBody : strippedBody;
}
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
EMOJI_REGEX.lastIndex = 0;

View file

@ -102,6 +102,16 @@ export default class Markdown {
// (https://github.com/vector-im/riot-web/issues/3154)
softbreak: '<br />',
});
// Trying to strip out the wrapping <p/> causes a lot more complication
// than it's worth, i think. For instance, this code will go and strip
// out any <p/> tag (no matter where it is in the tree) which doesn't
// contain \n's.
// On the flip side, <p/>s are quite opionated and restricted on where
// you can nest them.
//
// Let's try sending with <p/>s anyway for now, though.
/*
const real_paragraph = renderer.paragraph;
renderer.paragraph = function(node, entering) {
@ -114,16 +124,21 @@ export default class Markdown {
real_paragraph.call(this, node, entering);
}
};
*/
renderer.html_inline = html_if_tag_allowed;
renderer.html_block = function(node) {
/*
// as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown.
const isMultiLine = is_multi_line(node);
if (isMultiLine) this.cr();
*/
html_if_tag_allowed.call(this, node);
/*
if (isMultiLine) this.cr();
*/
};
return renderer.render(this.parsed);
@ -133,7 +148,10 @@ export default class Markdown {
* Render the markdown message to plain text. That is, essentially
* just remove any backslashes escaping what would otherwise be
* markdown syntax
* (to fix https://github.com/vector-im/riot-web/issues/2870)
* (to fix https://github.com/vector-im/riot-web/issues/2870).
*
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
* which has no formatting. Otherwise it emits HTML(!).
*/
toPlaintext() {
const renderer = new commonmark.HtmlRenderer({safe: false});
@ -156,6 +174,7 @@ export default class Markdown {
}
}
};
renderer.html_block = function(node) {
this.lit(node.literal);
if (is_multi_line(node) && node.next) this.lit('\n\n');

View file

@ -1,61 +1,30 @@
/*
Copyright 2015 - 2017 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector 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.
*/
import React from 'react';
import {
Editor,
EditorState,
Modifier,
ContentState,
ContentBlock,
convertFromHTML,
DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle,
CompositeDecorator,
SelectionState,
Entity,
} from 'draft-js';
import * as sdk from './index';
import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html';
import { SelectionRange } from "./autocomplete/Autocompleter";
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g,
CODE: /`[^`]*`/g,
STRIKETHROUGH: /~{2}[^~]*~{2}/g,
};
const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
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 const contentStateToHTML = (contentState: ContentState) => {
return stateToHTML(contentState, {
inlineStyles: {
UNDERLINE: {
element: 'u',
},
},
});
};
export function htmlToContentState(html: string): ContentState {
const blockArray = convertFromHTML(html).contentBlocks;
return ContentState.createFromBlockArray(blockArray);
}
function unicodeToEmojiUri(str) {
export function unicodeToEmojiUri(str) {
let replaceWith, unicode, alt;
if ((!emojione.unicodeAlt) || (emojione.sprites)) {
// if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames
@ -81,227 +50,3 @@ function unicodeToEmojiUri(str) {
return str;
}
/**
* Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end)
* From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html
*/
function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) {
const text = contentBlock.getText();
let matchArr, start;
while ((matchArr = regex.exec(text)) !== null) {
start = matchArr.index;
callback(start, start + matchArr[0].length);
}
}
// Workaround for https://github.com/facebook/draft-js/issues/414
const emojiDecorator = {
strategy: (contentState, contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
const uri = unicodeToEmojiUri(props.children[0].props.text);
const shortname = emojione.toShort(props.children[0].props.text);
const style = {
display: 'inline-block',
width: '1em',
maxHeight: '1em',
background: `url(${uri})`,
backgroundSize: 'contain',
backgroundPosition: 'center center',
overflow: 'hidden',
};
return (<span title={shortname} style={style}><span style={{opacity: 0}}>{ props.children }</span></span>);
},
};
/**
* Returns a composite decorator which has access to provided scope.
*/
export function getScopedRTDecorators(scope: any): CompositeDecorator {
return [emojiDecorator];
}
export function getScopedMDDecorators(scope: any): CompositeDecorator {
const markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
},
component: (props) => (
<span className={"mx_MarkdownElement mx_Markdown_" + style}>
{ props.children }
</span>
),
}));
markdownDecorators.push({
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
},
component: (props) => (
<a href="#" className="mx_MarkdownElement mx_Markdown_LINK">
{ props.children }
</a>
),
});
// markdownDecorators.push(emojiDecorator);
// TODO Consider renabling "syntax highlighting" when we can do it properly
return [emojiDecorator];
}
/**
* Passes rangeToReplace to modifyFn and replaces it in contentState with the result.
*/
export function modifyText(contentState: ContentState, rangeToReplace: SelectionState,
modifyFn: (text: string) => string, inlineStyle, entityKey): ContentState {
let getText = (key) => contentState.getBlockForKey(key).getText(),
startKey = rangeToReplace.getStartKey(),
startOffset = rangeToReplace.getStartOffset(),
endKey = rangeToReplace.getEndKey(),
endOffset = rangeToReplace.getEndOffset(),
text = "";
for (let currentKey = startKey;
currentKey && currentKey !== endKey;
currentKey = contentState.getKeyAfter(currentKey)) {
const blockText = getText(currentKey);
text += blockText.substring(startOffset, blockText.length);
// from now on, we'll take whole blocks
startOffset = 0;
}
// add remaining part of last block
text += getText(endKey).substring(startOffset, endOffset);
return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey);
}
/**
* Computes the plaintext offsets of the given SelectionState.
* Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc)
* Used by autocomplete to show completions when the current selection lies within, or at the edges of a command.
*/
export function selectionStateToTextOffsets(selectionState: SelectionState,
contentBlocks: Array<ContentBlock>): {start: number, end: number} {
let offset = 0, start = 0, end = 0;
for (const block of contentBlocks) {
if (selectionState.getStartKey() === block.getKey()) {
start = offset + selectionState.getStartOffset();
}
if (selectionState.getEndKey() === block.getKey()) {
end = offset + selectionState.getEndOffset();
break;
}
offset += block.getLength();
}
return {
start,
end,
};
}
export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();
// Subtract block lengths from `start` and `end` until they are less than the current
// block length (accounting for the NL at the end of each block). Set them to -1 to
// indicate that the corresponding selection state has been determined.
for (const block of contentBlocks) {
const blockLength = block.getLength();
// -1 indicating that the position start position has been found
if (start !== -1) {
if (start < blockLength + 1) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
anchorOffset: start,
});
start = -1; // selection state for the start calculated
} else {
start -= blockLength + 1; // +1 to account for newline between blocks
}
}
// -1 indicating that the position end position has been found
if (end !== -1) {
if (end < blockLength + 1) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
focusOffset: end,
});
end = -1; // selection state for the end calculated
} else {
end -= blockLength + 1; // +1 to account for newline between blocks
}
}
}
return selectionState;
}
// modified version of https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-emoji-plugin/src/modifiers/attachImmutableEntitiesToEmojis.js
export function attachImmutableEntitiesToEmoji(editorState: EditorState): EditorState {
const contentState = editorState.getCurrentContent();
const blocks = contentState.getBlockMap();
let newContentState = contentState;
blocks.forEach((block) => {
const plainText = block.getText();
const addEntityToEmoji = (start, end) => {
const existingEntityKey = block.getEntityAt(start);
if (existingEntityKey) {
// avoid manipulation in case the emoji already has an entity
const entity = newContentState.getEntity(existingEntityKey);
if (entity && entity.get('type') === 'emoji') {
return;
}
}
const selection = SelectionState.createEmpty(block.getKey())
.set('anchorOffset', start)
.set('focusOffset', end);
const emojiText = plainText.substring(start, end);
newContentState = newContentState.createEntity(
'emoji', 'IMMUTABLE', { emojiUnicode: emojiText },
);
const entityKey = newContentState.getLastCreatedEntityKey();
newContentState = Modifier.replaceText(
newContentState,
selection,
emojiText,
null,
entityKey,
);
};
findWithRegex(EMOJI_REGEX, block, addEntityToEmoji);
});
if (!newContentState.equals(contentState)) {
const oldSelection = editorState.getSelection();
editorState = EditorState.push(
editorState,
newContentState,
'convert-to-immutable-emojis',
);
// this is somewhat of a hack, we're undoing selection changes caused above
// it would be better not to make those changes in the first place
editorState = EditorState.forceSelection(editorState, oldSelection);
}
return editorState;
}
export function hasMultiLineSelection(editorState: EditorState): boolean {
const selectionState = editorState.getSelection();
const anchorKey = selectionState.getAnchorKey();
const currentContent = editorState.getCurrentContent();
const currentContentBlock = currentContent.getBlockForKey(anchorKey);
const start = selectionState.getStartOffset();
const end = selectionState.getEndOffset();
const selectedText = currentContentBlock.getText().slice(start, end);
return selectedText.includes('\n');
}

View file

@ -476,6 +476,7 @@ const aliases = {
j: "join",
};
/**
* Process the given text for /commands and perform them.
* @param {string} roomId The room in which the command was performed.
@ -488,7 +489,7 @@ export function processCommandInput(roomId, input) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/' || input[1] === '/') return null; // not a command
if (input[0] !== '/') return null; // not a command
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
let cmd;

View file

@ -20,13 +20,19 @@ import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider {
constructor(commandRegex?: RegExp) {
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) {
if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set');
}
this.commandRegex = commandRegex;
}
if (forcedCommandRegex) {
if (!forcedCommandRegex.global) {
throw new Error('forcedCommandRegex must have global flag set');
}
this.forcedCommandRegex = forcedCommandRegex;
}
}
destroy() {
@ -40,7 +46,7 @@ export default class AutocompleteProvider {
let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) {
commandRegex = /\S+/g;
commandRegex = this.forcedCommandRegex || /\S+/g;
}
if (commandRegex == null) {

View file

@ -29,8 +29,9 @@ import NotifProvider from './NotifProvider';
import Promise from 'bluebird';
export type SelectionRange = {
start: number,
end: number
beginning: boolean, // whether the selection is in the first block of the editor or not
start: number, // byte offset relative to the start anchor of the current editor selection.
end: number, // byte offset relative to the end anchor of the current editor selection.
};
export type Completion = {
@ -80,12 +81,12 @@ export default class Autocompleter {
// Array of inspections of promises that might timeout. Instead of allowing a
// single timeout to reject the Promise.all, reflect each one and once they've all
// settled, filter for the fulfilled ones
this.providers.map((provider) => {
return provider
this.providers.map(provider =>
provider
.getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT)
.reflect();
}),
.reflect()
),
);
return completionsList.filter(

View file

@ -42,6 +42,7 @@ export default class CommandProvider extends AutocompleteProvider {
if (!command) return [];
let matches = [];
// check if the full match differs from the first word (i.e. returns false if the command has args)
if (command[0] !== command[1]) {
// The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/`

View file

@ -41,6 +41,7 @@ export default class NotifProvider extends AutocompleteProvider {
if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) {
return [{
completion: '@room',
completionId: '@room',
suffix: ' ',
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />

View file

@ -0,0 +1,89 @@
/*
Copyright 2018 New Vector 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.
*/
// Based originally on slate-plain-serializer
import { Block } from 'slate';
/**
* Plain text serializer, which converts a Slate `value` to a plain text string,
* serializing pills into various different formats as required.
*
* @type {PlainWithPillsSerializer}
*/
class PlainWithPillsSerializer {
/*
* @param {String} options.pillFormat - either 'md', 'plain', 'id'
*/
constructor(options = {}) {
const {
pillFormat = 'plain',
} = options;
this.pillFormat = pillFormat;
}
/**
* Serialize a Slate `value` to a plain text string,
* serializing pills as either MD links, plain text representations or
* ID representations as required.
*
* @param {Value} value
* @return {String}
*/
serialize = value => {
return this._serializeNode(value.document);
}
/**
* Serialize a `node` to plain text.
*
* @param {Node} node
* @return {String}
*/
_serializeNode = node => {
if (
node.object == 'document' ||
(node.object == 'block' && Block.isBlockList(node.nodes))
) {
return node.nodes.map(this._serializeNode).join('\n');
} else if (node.type == 'emoji') {
return node.data.get('emojiUnicode');
} else if (node.type == 'pill') {
switch (this.pillFormat) {
case 'plain':
return node.data.get('completion');
case 'md':
return `[${ node.data.get('completion') }](${ node.data.get('href') })`;
case 'id':
return node.data.get('completionId') || node.data.get('completion');
}
} else if (node.nodes) {
return node.nodes.map(this._serializeNode).join('');
} else {
return node.text;
}
}
}
/**
* Export.
*
* @type {PlainWithPillsSerializer}
*/
export default PlainWithPillsSerializer;

View file

@ -51,12 +51,6 @@ export default class RoomProvider extends AutocompleteProvider {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const client = MatrixClientPeg.get();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force);
@ -80,6 +74,7 @@ export default class RoomProvider extends AutocompleteProvider {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return {
completion: displayAlias,
completionId: displayAlias,
suffix: ' ',
href: makeRoomPermalink(displayAlias),
component: (

View file

@ -33,14 +33,16 @@ import type {Completion, SelectionRange} from "./Autocompleter";
const USER_REGEX = /\B@\S*/g;
// used when you hit 'tab' - we allow some separator chars at the beginning
// to allow you to tab-complete /mat into /(matthew)
const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g;
export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null;
room: Room = null;
constructor(room: Room) {
super(USER_REGEX, {
keys: ['name'],
});
constructor(room) {
super(USER_REGEX, FORCED_USER_REGEX);
this.room = room;
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'],
@ -91,12 +93,6 @@ export default class UserProvider extends AutocompleteProvider {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) {
return [];
}
// lazy-load user list into matcher
if (this.users === null) this._makeUsers();
@ -114,7 +110,8 @@ export default class UserProvider extends AutocompleteProvider {
// Length of completion should equal length of text in decorator. draft-js
// relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''),
suffix: range.start === 0 ? ': ' : ' ',
completionId: user.userId,
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId),
component: (
<PillCompletion

View file

@ -308,12 +308,12 @@ module.exports = React.createClass({
throw err;
}
});
}
} else if (room) {
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.setState({isPeeking: false});
}
}
},
_shouldShowApps: function(room) {

View file

@ -178,7 +178,7 @@ module.exports = React.createClass({
onQuoteClick: function() {
dis.dispatch({
action: 'quote',
text: this.props.eventTileOps.getInnerText(),
event: this.props.mxEvent,
});
this.closeMenu();
},

View file

@ -164,6 +164,7 @@ export default class AppTile extends React.Component {
PersistedElement.destroyElement(this._persistKey);
ActiveWidgetStore.delWidgetMessaging(this.props.id);
ActiveWidgetStore.delWidgetCapabilities(this.props.id);
ActiveWidgetStore.delRoomId(this.props.id);
}
}
@ -349,6 +350,7 @@ export default class AppTile extends React.Component {
if (!ActiveWidgetStore.getWidgetMessaging(this.props.id)) {
this._setupWidgetMessaging();
}
ActiveWidgetStore.setRoomId(this.props.id, this.props.room.roomId);
this.setState({loading: false});
}
@ -528,6 +530,8 @@ export default class AppTile extends React.Component {
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
const iframeFeatures = "microphone; camera; encrypted-media;";
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
if (this.props.show) {
const loadingElement = (
<div className="mx_AppLoading_spinner_fadeIn">
@ -536,20 +540,20 @@ export default class AppTile extends React.Component {
);
if (this.state.initialising) {
appTileBody = (
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ loadingElement }
</div>
);
} else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) {
appTileBody = (
<div className="mx_AppTileBody">
<div className={appTileBodyClass}>
<AppWarning errorMsg="Error - Mixed content" />
</div>
);
} else {
appTileBody = (
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ this.state.loading && loadingElement }
{ /*
The "is" attribute in the following iframe tag is needed in order to enable rendering of the
@ -579,7 +583,7 @@ export default class AppTile extends React.Component {
} else {
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = (
<div className="mx_AppTileBody">
<div className={appTileBodyClass}>
<AppPermission
isRoomEncrypted={isRoomEncrypted}
url={this.state.widgetUrl}
@ -692,6 +696,8 @@ AppTile.propTypes = {
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
// Optional. If set, renders a smaller view of the widget
miniMode: PropTypes.bool,
// UserId of the current user
userId: PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget
@ -744,4 +750,5 @@ AppTile.defaultProps = {
handleMinimisePointerEvents: false,
whitelistCapabilities: [],
userWidget: false,
miniMode: false,
};

View file

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require('react');
const ReactDOM = require('react-dom');
const PropTypes = require('prop-types');
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import ResizeObserver from 'resize-observer-polyfill';
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -62,6 +64,9 @@ export default class PersistedElement extends React.Component {
super();
this.collectChildContainer = this.collectChildContainer.bind(this);
this.collectChild = this.collectChild.bind(this);
this._onContainerResize = this._onContainerResize.bind(this);
this.resizeObserver = new ResizeObserver(this._onContainerResize);
}
/**
@ -83,7 +88,13 @@ export default class PersistedElement extends React.Component {
}
collectChildContainer(ref) {
if (this.childContainer) {
this.resizeObserver.unobserve(this.childContainer);
}
this.childContainer = ref;
if (ref) {
this.resizeObserver.observe(ref);
}
}
collectChild(ref) {
@ -101,6 +112,11 @@ export default class PersistedElement extends React.Component {
componentWillUnmount() {
this.updateChildVisibility(this.child, false);
this.resizeObserver.disconnect();
}
_onContainerResize() {
this.updateChildPosition(this.child, this.childContainer);
}
updateChild() {

View file

@ -0,0 +1,87 @@
/*
Copyright 2018 New Vector 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.
*/
import React from 'react';
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
module.exports = React.createClass({
displayName: 'PersistentApp',
getInitialState: function() {
return {
roomId: RoomViewStore.getRoomId(),
};
},
componentWillMount: function() {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
},
componentWillUnmount: function() {
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
},
_onRoomViewStoreUpdate: function(payload) {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
this.setState({
roomId: RoomViewStore.getRoomId(),
});
},
render: function() {
if (ActiveWidgetStore.getPersistentWidgetId()) {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(ActiveWidgetStore.getPersistentWidgetId());
if (this.state.roomId !== persistentWidgetInRoomId) {
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
// get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
});
const app = WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.sender, persistentWidgetInRoomId,
);
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
id={app.id}
url={app.url}
name={app.name}
type={app.type}
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}
show={true}
creatorUserId={app.creatorUserId}
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
showDelete={false}
showMinimise={false}
miniMode={true}
/>;
}
}
return null;
},
});

View file

@ -62,6 +62,8 @@ const Pill = React.createClass({
room: PropTypes.instanceOf(Room),
// Whether to include an avatar in the pill
shouldShowPillAvatar: PropTypes.bool,
// Whether to render this pill as if it were highlit by a selection
isSelected: PropTypes.bool,
},
@ -268,6 +270,7 @@ const Pill = React.createClass({
const classes = classNames(pillClass, {
"mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId,
"mx_UserPill_selected": this.props.isSelected,
});
if (this.state.pillType) {

View file

@ -110,55 +110,6 @@ module.exports = React.createClass({
}
},
/**
* 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, sender) {
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) : '',
// TODO: Namespace themes through some standard
'$theme': SettingsStore.getValue("theme"),
};
app.id = appId;
app.name = app.name || app.type;
if (app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
}
app.url = this.encodeUri(app.url, params);
app.creatorUserId = (sender && sender.userId) ? sender.userId : null;
return app;
},
onRoomStateEvents: function(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') {
return;
@ -171,7 +122,7 @@ module.exports = React.createClass({
this.props.room.roomId, WidgetUtils.getRoomWidgets(this.props.room),
);
return widgets.map((ev) => {
return this._initAppConfig(ev.getStateKey(), ev.getContent(), ev.sender);
return WidgetUtils.makeAppConfig(ev.getStateKey(), ev.getContent(), ev.sender);
});
},
@ -219,15 +170,8 @@ module.exports = React.createClass({
},
render: function() {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", this.props.room.room_id);
const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = enableScreenshots ? ["m.capability.screenshot"] : [];
// Obviously anyone that can add a widget can claim it's a jitsi widget,
// so this doesn't really offer much over the set of domains we load
// widgets from at all, but it probably makes sense for sanity.
if (app.type == 'jitsi') capWhitelist.push("m.always_on_screen");
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
return (<AppTile
key={app.id}

View file

@ -114,7 +114,7 @@ export default class Autocomplete extends React.Component {
processQuery(query, selection) {
return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete,
query, selection, this.state.forceComplete
).then((completions) => {
// Only ever process the completions for the most recent query being processed
if (query !== this.queryRequested) {
@ -263,7 +263,6 @@ export default class Autocomplete extends React.Component {
const componentPosition = position;
position++;
const onMouseMove = () => this.setSelection(componentPosition);
const onClick = () => {
this.setSelection(componentPosition);
this.onCompletionClicked();
@ -273,7 +272,6 @@ export default class Autocomplete extends React.Component {
key: i,
ref: `completion${position - 1}`,
className,
onMouseMove,
onClick,
});
});

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import CallHandler from '../../../CallHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
@ -26,6 +26,17 @@ import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Stickerpicker from './Stickerpicker';
const formatButtonList = [
_td("bold"),
_td("italic"),
_td("deleted"),
_td("underlined"),
_td("inline-code"),
_td("block-quote"),
_td("bulleted-list"),
_td("numbered-list"),
];
export default class MessageComposer extends React.Component {
constructor(props, context) {
super(props, context);
@ -35,7 +46,6 @@ export default class MessageComposer extends React.Component {
this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.uploadFiles = this.uploadFiles.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
this.onInputContentChanged = this.onInputContentChanged.bind(this);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
@ -44,13 +54,10 @@ export default class MessageComposer extends React.Component {
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this.state = {
autocompleteQuery: '',
selection: null,
inputState: {
style: [],
marks: [],
blockType: null,
isRichtextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'),
wordCount: 0,
isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'),
},
showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'),
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
@ -175,13 +182,6 @@ export default class MessageComposer extends React.Component {
});
}
onInputContentChanged(content: string, selection: {start: number, end: number}) {
this.setState({
autocompleteQuery: content,
selection,
});
}
onInputStateChanged(inputState) {
this.setState({inputState});
}
@ -192,7 +192,7 @@ export default class MessageComposer extends React.Component {
}
}
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", event) {
onFormatButtonClicked(name, event) {
event.preventDefault();
this.messageComposerInput.onFormatButtonClicked(name, event);
}
@ -204,7 +204,7 @@ export default class MessageComposer extends React.Component {
onToggleMarkdownClicked(e) {
e.preventDefault(); // don't steal focus from the editor!
this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled);
this.messageComposerInput.enableRichtext(!this.state.inputState.isRichTextEnabled);
}
render() {
@ -280,14 +280,14 @@ export default class MessageComposer extends React.Component {
</div>
);
const formattingButton = (
const formattingButton = this.state.inputState.isRichTextEnabled ? (
<img className="mx_MessageComposer_formatting"
title={_t("Show Text Formatting Toolbar")}
src="img/button-text-formatting.svg"
onClick={this.onToggleFormattingClicked}
style={{visibility: this.state.showFormatting ? 'hidden' : 'visible'}}
key="controls_formatting" />
);
) : null;
let placeholderText;
if (this.state.isQuoting) {
@ -314,7 +314,6 @@ export default class MessageComposer extends React.Component {
room={this.props.room}
placeholder={placeholderText}
onFilesPasted={this.uploadFiles}
onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />,
formattingButton,
stickerpickerButton,
@ -331,11 +330,14 @@ export default class MessageComposer extends React.Component {
);
}
const {style, blockType} = this.state.inputState;
const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map(
(name) => {
const active = style.includes(name) || blockType === name;
const suffix = active ? '-o-n' : '';
let formatBar;
if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) {
const {marks, blockType} = this.state.inputState;
const formatButtons = formatButtonList.map((name) => {
// special-case to match the md serializer and the special-case in MessageComposerInput.js
const markName = name === 'inline-code' ? 'code' : name;
const active = marks.some(mark => mark.type === markName) || blockType === name;
const suffix = active ? '-on' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
return <img className={className}
@ -347,6 +349,23 @@ export default class MessageComposer extends React.Component {
},
);
formatBar =
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{flex: 1}}></div>
<img title={this.state.inputState.isRichTextEnabled ? _t("Turn Markdown on") : _t("Turn Markdown off")}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={`img/button-md-${!this.state.inputState.isRichTextEnabled}.png`} />
<img title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src="img/icon-text-cancel.svg" />
</div>
</div>
}
return (
<div className="mx_MessageComposer">
<div className="mx_MessageComposer_wrapper">
@ -354,20 +373,7 @@ export default class MessageComposer extends React.Component {
{ controls }
</div>
</div>
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar" style={this.state.showFormatting ? {} : {display: 'none'}}>
{ formatButtons }
<div style={{flex: 1}}></div>
<img title={this.state.inputState.isRichtextEnabled ? _t("Turn Markdown on") : _t("Turn Markdown off")}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={`img/button-md-${!this.state.inputState.isRichtextEnabled}.png`} />
<img title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src="img/icon-text-cancel.svg" />
</div>
</div>
{ formatBar }
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -92,7 +92,8 @@ module.exports = React.createClass({
/>
);
}
return null;
const PersistentApp = sdk.getComponent('elements.PersistentApp');
return <PersistentApp />;
},
});

View file

@ -39,9 +39,13 @@
"Conference calling is in development and may not be reliable.": "Conference calling is in development and may not be reliable.",
"Failed to set up conference call": "Failed to set up conference call",
"Conference call failed.": "Conference call failed.",
"Could not connect to the integration server": "Could not connect to the integration server",
"A conference call could not be started because the intgrations server is not available": "A conference call could not be started because the intgrations server is not available",
"Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!",
"A call is already in progress!": "A call is already in progress!",
"Permission Required": "Permission Required",
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
"The file '%(fileName)s' failed to upload": "The file '%(fileName)s' failed to upload",
"The file '%(fileName)s' exceeds this home server's size limit for uploads": "The file '%(fileName)s' exceeds this home server's size limit for uploads",
"Upload Failed": "Upload Failed",
@ -383,6 +387,14 @@
"Invited": "Invited",
"Filter room members": "Filter room members",
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)",
"bold": "bold",
"italic": "italic",
"deleted": "deleted",
"underlined": "underlined",
"inline-code": "inline-code",
"block-quote": "block-quote",
"bulleted-list": "bulleted-list",
"numbered-list": "numbered-list",
"Attachment": "Attachment",
"At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.",
"Upload Files": "Upload Files",
@ -407,14 +419,6 @@
"Command error": "Command error",
"Unable to reply": "Unable to reply",
"At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.",
"bold": "bold",
"italic": "italic",
"strike": "strike",
"underline": "underline",
"code": "code",
"quote": "quote",
"bullet": "bullet",
"numbullet": "numbullet",
"Markdown is disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled",
"Unpin Message": "Unpin Message",
@ -713,7 +717,6 @@
"Uploaded on %(date)s by %(user)s": "Uploaded on %(date)s by %(user)s",
"Download this file": "Download this file",
"Integrations Error": "Integrations Error",
"Could not connect to the integration server": "Could not connect to the integration server",
"Manage Integrations": "Manage Integrations",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times",

View file

@ -32,6 +32,9 @@ class ActiveWidgetStore {
// A WidgetMessaging instance for each widget ID
this._widgetMessagingByWidgetId = {};
// What room ID each widget is associated with (if it's a room widget)
this._roomIdByWidgetId = {};
}
setWidgetPersistence(widgetId, val) {
@ -46,6 +49,10 @@ class ActiveWidgetStore {
return this._persistentWidgetId === widgetId;
}
getPersistentWidgetId() {
return this._persistentWidgetId;
}
setWidgetCapabilities(widgetId, caps) {
this._capsByWidgetId[widgetId] = caps;
}
@ -76,6 +83,18 @@ class ActiveWidgetStore {
delete this._widgetMessagingByWidgetId[widgetId];
}
}
getRoomId(widgetId) {
return this._roomIdByWidgetId[widgetId];
}
setRoomId(widgetId, roomId) {
this._roomIdByWidgetId[widgetId] = roomId;
}
delRoomId(widgetId) {
delete this._roomIdByWidgetId[widgetId];
}
}
if (global.singletonActiveWidgetStore === undefined) {

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 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.
@ -13,60 +13,49 @@ 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 dis from '../dispatcher';
import {Store} from 'flux/utils';
import {convertToRaw, convertFromRaw} from 'draft-js';
import { Value } from 'slate';
const INITIAL_STATE = {
editorStateMap: localStorage.getItem('content_state') ?
JSON.parse(localStorage.getItem('content_state')) : {},
};
const localStoragePrefix = 'editor_state_';
/**
* A class for storing application state to do with the message composer. This is a simple
* flux store that listens for actions and updates its state accordingly, informing any
* listeners (views) of state changes.
* A class for storing application state to do with the message composer (specifically in-progress message drafts).
* It does not worry about cleaning up on log out as this is handled in Lifecycle.js by localStorage.clear()
*/
class MessageComposerStore extends Store {
class MessageComposerStore {
constructor() {
super(dis);
// Initialise state
this._state = Object.assign({}, INITIAL_STATE);
this.prefix = localStoragePrefix;
}
_setState(newState) {
this._state = Object.assign(this._state, newState);
this.__emitChange();
_getKey(roomId: string): string {
return this.prefix + roomId;
}
__onDispatch(payload) {
switch (payload.action) {
case 'content_state':
this._contentState(payload);
break;
case 'on_logged_out':
this.reset();
break;
}
setEditorState(roomId: string, editorState: Value, richText: boolean) {
localStorage.setItem(this._getKey(roomId), JSON.stringify({
editor_state: editorState.toJSON({
preserveSelection: true,
// XXX: re-hydrating history is not currently supported by fromJSON
// preserveHistory: true,
// XXX: this seems like a workaround for selection.isSet being based on anchorKey instead of anchorPath
preserveKeys: true,
}),
rich_text: richText,
}));
}
_contentState(payload) {
const editorStateMap = this._state.editorStateMap;
editorStateMap[payload.room_id] = convertToRaw(payload.content_state);
localStorage.setItem('content_state', JSON.stringify(editorStateMap));
this._setState({
editorStateMap: editorStateMap,
});
getEditorState(roomId): {editor_state: Value, rich_text: boolean} {
const stateStr = localStorage.getItem(this._getKey(roomId));
let state;
if (stateStr) {
state = JSON.parse(stateStr);
// if it does not have the fields we expect then bail
if (!state || state.rich_text === undefined || state.editor_state === undefined) return;
state.editor_state = Value.fromJSON(state.editor_state);
}
getContentState(roomId) {
return this._state.editorStateMap[roomId] ?
convertFromRaw(this._state.editorStateMap[roomId]) : null;
}
reset() {
this._state = Object.assign({}, INITIAL_STATE);
return state;
}
}

View file

@ -24,6 +24,27 @@ import WidgetEchoStore from '../stores/WidgetEchoStore';
// How long we wait for the state event echo to come back from the server
// before waitFor[Room/User]Widget rejects its promise
const WIDGET_WAIT_TIME = 20000;
import SettingsStore from "../settings/SettingsStore";
/**
* 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'.
*/
function encodeUri(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
}
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
@ -333,4 +354,47 @@ export default class WidgetUtils {
});
return client.setAccountData('m.widgets', userWidgets);
}
static makeAppConfig(appId, app, sender, roomId) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const user = MatrixClientPeg.get().getUser(myUserId);
const params = {
'$matrix_user_id': myUserId,
'$matrix_room_id': roomId,
'$matrix_display_name': user ? user.displayName : myUserId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
// TODO: Namespace themes through some standard
'$theme': SettingsStore.getValue("theme"),
};
app.id = appId;
app.name = app.name || app.type;
if (app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
}
app.url = encodeUri(app.url, params);
app.creatorUserId = (sender && sender.userId) ? sender.userId : null;
return app;
}
static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
const capWhitelist = enableScreenshots ? ["m.capability.screenshot"] : [];
// Obviously anyone that can add a widget can claim it's a jitsi widget,
// so this doesn't really offer much over the set of domains we load
// widgets from at all, but it probably makes sense for sanity.
if (appType == 'jitsi') capWhitelist.push("m.always_on_screen");
return capWhitelist;
}
}

View file

@ -20,7 +20,9 @@ function addTextToDraft(text) {
}
}
describe('MessageComposerInput', () => {
// FIXME: These tests need to be updated from Draft to Slate.
xdescribe('MessageComposerInput', () => {
let parentDiv = null,
sandbox = null,
client = null,
@ -69,7 +71,7 @@ describe('MessageComposerInput', () => {
'mx_MessageComposer_input_markdownIndicator');
ReactTestUtils.Simulate.click(indicator);
expect(mci.state.isRichtextEnabled).toEqual(false, 'should have changed mode');
expect(mci.state.isRichTextEnabled).toEqual(false, 'should have changed mode');
done();
});
});