Merge remote-tracking branch 'origin/develop' into dbkr/widget_echo
|  | @ -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", | ||||
|  |  | |||
|  | @ -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. | ||||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -54,6 +54,10 @@ limitations under the License. | |||
| 
 | ||||
| } | ||||
| 
 | ||||
| .mx_LeftPanel .mx_AppTileFullWidth  { | ||||
|     height: 132px; | ||||
| } | ||||
| 
 | ||||
| .mx_LeftPanel .mx_RoomList_scrollbar { | ||||
|     order: 1; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
| Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB | 
| Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB | 
| Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB | 
| Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB | 
| Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB | 
| Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB | 
| Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB | 
| Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB | 
| Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB | 
| Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB | 
| Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB | 
| Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB | 
|  | @ -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); | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -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)); | ||||
|             } | ||||
|         } else { | ||||
|             if (this.format === 'markdown') { | ||||
|                 return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML()); | ||||
|             } | ||||
|         } | ||||
|         // history item has format === outputFormat
 | ||||
|         return contentState; | ||||
|     static fromJSON(obj: Object): HistoryItem { | ||||
|         return new HistoryItem( | ||||
|             Value.fromJSON(obj.value), | ||||
|             obj.format, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     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++) { | ||||
|             this.history.push( | ||||
|                 Object.assign(new HistoryItem(), JSON.parse(item)), | ||||
|             ); | ||||
|             try { | ||||
|                 this.history.push( | ||||
|                     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]; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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. | ||||
|  | @ -409,19 +374,22 @@ class TextHighlighter extends BaseHighlighter { | |||
| } | ||||
| 
 | ||||
| 
 | ||||
|     /* turn a matrix event body into html | ||||
|      * | ||||
|      * content: 'content' of the MatrixEvent | ||||
|      * | ||||
|      * highlights: optional list of words to highlight, ordered by longest word first | ||||
|      * | ||||
|      * 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 | ||||
|      */ | ||||
| /* turn a matrix event body into html | ||||
|  * | ||||
|  * content: 'content' of the MatrixEvent | ||||
|  * | ||||
|  * highlights: optional list of words to highlight, ordered by longest word first | ||||
|  * | ||||
|  * 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; | ||||
| 
 | ||||
|         bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : 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; | ||||
|  |  | |||
|  | @ -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'); | ||||
|  |  | |||
							
								
								
									
										297
									
								
								src/RichText.js
								
								
								
								
							
							
						
						|  | @ -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, | ||||
| }; | ||||
| import { SelectionRange } from "./autocomplete/Autocompleter"; | ||||
| 
 | ||||
| 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'); | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
|  | @ -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 `/`
 | ||||
|  |  | |||
|  | @ -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")} /> | ||||
|  |  | |||
|  | @ -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; | ||||
|  | @ -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: ( | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -308,11 +308,11 @@ 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}); | ||||
|             } | ||||
|         } else if (room) { | ||||
|             // Stop peeking because we have joined this room previously
 | ||||
|             MatrixClientPeg.get().stopPeeking(); | ||||
|             this.setState({isPeeking: false}); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|  |  | |||
|  | @ -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(); | ||||
|     }, | ||||
|  |  | |||
|  | @ -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, | ||||
| }; | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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; | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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, | ||||
|                 }); | ||||
|             }); | ||||
|  |  | |||
|  | @ -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} | ||||
|  | @ -344,8 +346,25 @@ export default class MessageComposer extends React.Component { | |||
|                             key={name} | ||||
|                             src={`img/button-text-${name}${suffix}.svg`} | ||||
|                             height="17" />; | ||||
|             }, | ||||
|         ); | ||||
|                 }, | ||||
|             ); | ||||
| 
 | ||||
|             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"> | ||||
|  | @ -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> | ||||
|         ); | ||||
|     } | ||||
|  |  | |||
|  | @ -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 />; | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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, | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|     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); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     _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, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     getContentState(roomId) { | ||||
|         return this._state.editorStateMap[roomId] ? | ||||
|             convertFromRaw(this._state.editorStateMap[roomId]) : null; | ||||
|     } | ||||
| 
 | ||||
|     reset() { | ||||
|         this._state = Object.assign({}, INITIAL_STATE); | ||||
|         return state; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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(); | ||||
|         }); | ||||
|     }); | ||||
|  | @ -299,4 +301,4 @@ describe('MessageComposerInput', () => { | |||
|         expect(spy.args[0][1].body).toEqual('[Click here](https://some.lovely.url)'); | ||||
|         expect(spy.args[0][1].formatted_body).toEqual('<a href="https://some.lovely.url">Click here</a>'); | ||||
|     }); | ||||
| }); | ||||
| }); | ||||
 David Baker
						David Baker