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

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

View file

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

View file

@ -15,46 +15,44 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ContentState, convertToRaw, convertFromRaw} from 'draft-js';
import * as RichText from './RichText';
import Markdown from './Markdown';
import { Value } from 'slate';
import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown';
type MessageFormat = 'rich' | 'markdown';
class HistoryItem {
// Keeping message for backwards-compatibility
message: string;
rawContentState: RawDraftContentState;
format: MessageFormat = 'html';
// We store history items in their native format to ensure history is accurate
// and then convert them if our RTE has subsequently changed format.
value: Value;
format: MessageFormat = 'rich';
constructor(contentState: ?ContentState, format: ?MessageFormat) {
this.rawContentState = contentState ? convertToRaw(contentState) : null;
constructor(value: ?Value, format: ?MessageFormat) {
this.value = value;
this.format = format;
}
toContentState(outputFormat: MessageFormat): ContentState {
const contentState = convertFromRaw(this.rawContentState);
if (outputFormat === 'markdown') {
if (this.format === 'html') {
return ContentState.createFromText(RichText.stateToMarkdown(contentState));
}
} 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];
}
}

View file

@ -112,41 +112,6 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
/>;
}
export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = html;
if (contentDiv.children.length === 0) {
return contentDiv.innerHTML;
}
let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML;
// Don't add a <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> is
// redundant. This is a workaround for a bug in draft-js-export-html:
// https://github.com/sstur/draft-js-export-html/issues/62
contentHTML += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} else {
const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true));
contentHTML += temp.innerHTML;
}
}
return contentHTML;
}
/*
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
@ -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;

View file

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

View file

@ -1,61 +1,30 @@
/*
Copyright 2015 - 2017 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {
Editor,
EditorState,
Modifier,
ContentState,
ContentBlock,
convertFromHTML,
DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle,
CompositeDecorator,
SelectionState,
Entity,
} from 'draft-js';
import * as sdk from './index';
import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter";
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g,
CODE: /`[^`]*`/g,
STRIKETHROUGH: /~{2}[^~]*~{2}/g,
};
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');
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,89 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Based originally on slate-plain-serializer
import { Block } from 'slate';
/**
* Plain text serializer, which converts a Slate `value` to a plain text string,
* serializing pills into various different formats as required.
*
* @type {PlainWithPillsSerializer}
*/
class PlainWithPillsSerializer {
/*
* @param {String} options.pillFormat - either 'md', 'plain', 'id'
*/
constructor(options = {}) {
const {
pillFormat = 'plain',
} = options;
this.pillFormat = pillFormat;
}
/**
* Serialize a Slate `value` to a plain text string,
* serializing pills as either MD links, plain text representations or
* ID representations as required.
*
* @param {Value} value
* @return {String}
*/
serialize = value => {
return this._serializeNode(value.document);
}
/**
* Serialize a `node` to plain text.
*
* @param {Node} node
* @return {String}
*/
_serializeNode = node => {
if (
node.object == 'document' ||
(node.object == 'block' && Block.isBlockList(node.nodes))
) {
return node.nodes.map(this._serializeNode).join('\n');
} else if (node.type == 'emoji') {
return node.data.get('emojiUnicode');
} else if (node.type == 'pill') {
switch (this.pillFormat) {
case 'plain':
return node.data.get('completion');
case 'md':
return `[${ node.data.get('completion') }](${ node.data.get('href') })`;
case 'id':
return node.data.get('completionId') || node.data.get('completion');
}
} else if (node.nodes) {
return node.nodes.map(this._serializeNode).join('');
} else {
return node.text;
}
}
}
/**
* Export.
*
* @type {PlainWithPillsSerializer}
*/
export default PlainWithPillsSerializer;

View file

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

View file

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

View file

@ -308,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});
}
},

View file

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

View file

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

View file

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

View file

@ -0,0 +1,87 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
module.exports = React.createClass({
displayName: 'PersistentApp',
getInitialState: function() {
return {
roomId: RoomViewStore.getRoomId(),
};
},
componentWillMount: function() {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
},
componentWillUnmount: function() {
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
},
_onRoomViewStoreUpdate: function(payload) {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
this.setState({
roomId: RoomViewStore.getRoomId(),
});
},
render: function() {
if (ActiveWidgetStore.getPersistentWidgetId()) {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(ActiveWidgetStore.getPersistentWidgetId());
if (this.state.roomId !== persistentWidgetInRoomId) {
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
// get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
});
const app = WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.sender, persistentWidgetInRoomId,
);
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
id={app.id}
url={app.url}
name={app.name}
type={app.type}
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}
show={true}
creatorUserId={app.creatorUserId}
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
showDelete={false}
showMinimise={false}
miniMode={true}
/>;
}
}
return null;
},
});

View file

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

View file

@ -110,55 +110,6 @@ module.exports = React.createClass({
}
},
/**
* Encodes a URI according to a set of template variables. Variables will be
* passed through encodeURIComponent.
* @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'.
* @param {Object} variables The key/value pairs to replace the template
* variables with. E.g. { '$bar': 'baz' }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
encodeUri: function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
},
_initAppConfig: function(appId, app, sender) {
const user = MatrixClientPeg.get().getUser(this.props.userId);
const params = {
'$matrix_user_id': this.props.userId,
'$matrix_room_id': this.props.room.roomId,
'$matrix_display_name': user ? user.displayName : this.props.userId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
// TODO: Namespace themes through some standard
'$theme': SettingsStore.getValue("theme"),
};
app.id = appId;
app.name = app.name || app.type;
if (app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
}
app.url = this.encodeUri(app.url, params);
app.creatorUserId = (sender && sender.userId) ? sender.userId : null;
return app;
},
onRoomStateEvents: function(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') {
return;
@ -171,7 +122,7 @@ module.exports = React.createClass({
this.props.room.roomId, WidgetUtils.getRoomWidgets(this.props.room),
);
return widgets.map((ev) => {
return this._initAppConfig(ev.getStateKey(), ev.getContent(), ev.sender);
return WidgetUtils.makeAppConfig(ev.getStateKey(), ev.getContent(), ev.sender);
});
},
@ -219,15 +170,8 @@ module.exports = React.createClass({
},
render: function() {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", this.props.room.room_id);
const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = enableScreenshots ? ["m.capability.screenshot"] : [];
// Obviously anyone that can add a widget can claim it's a jitsi widget,
// so this doesn't really offer much over the set of domains we load
// widgets from at all, but it probably makes sense for sanity.
if (app.type == 'jitsi') capWhitelist.push("m.always_on_screen");
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
return (<AppTile
key={app.id}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -13,60 +13,49 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from '../dispatcher';
import {Store} from 'flux/utils';
import {convertToRaw, convertFromRaw} from 'draft-js';
import { Value } from 'slate';
const INITIAL_STATE = {
editorStateMap: localStorage.getItem('content_state') ?
JSON.parse(localStorage.getItem('content_state')) : {},
};
const localStoragePrefix = 'editor_state_';
/**
* A class for storing application state to do with the message composer. This is a simple
* flux store that listens for actions and updates its state accordingly, informing any
* listeners (views) of state changes.
* A class for storing application state to do with the message composer (specifically in-progress message drafts).
* It does not worry about cleaning up on log out as this is handled in Lifecycle.js by localStorage.clear()
*/
class MessageComposerStore extends Store {
class MessageComposerStore {
constructor() {
super(dis);
// Initialise state
this._state = Object.assign({}, INITIAL_STATE);
this.prefix = localStoragePrefix;
}
_setState(newState) {
this._state = Object.assign(this._state, newState);
this.__emitChange();
_getKey(roomId: string): string {
return this.prefix + roomId;
}
__onDispatch(payload) {
switch (payload.action) {
case 'content_state':
this._contentState(payload);
break;
case 'on_logged_out':
this.reset();
break;
setEditorState(roomId: string, editorState: Value, richText: boolean) {
localStorage.setItem(this._getKey(roomId), JSON.stringify({
editor_state: editorState.toJSON({
preserveSelection: true,
// XXX: re-hydrating history is not currently supported by fromJSON
// preserveHistory: true,
// XXX: this seems like a workaround for selection.isSet being based on anchorKey instead of anchorPath
preserveKeys: true,
}),
rich_text: richText,
}));
}
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;
}
}

View file

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