Merge branches 'develop' and 't3chguy/i18n_analytics' of github.com:matrix-org/matrix-react-sdk into t3chguy/i18n_analytics

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

# Conflicts:
#	src/Analytics.js
pull/21833/head
Michael Telatynski 2017-08-10 13:54:55 +01:00
commit b19c1010aa
No known key found for this signature in database
GPG Key ID: 0435A1D4BBD34D64
25 changed files with 694 additions and 168 deletions

View File

@ -4,7 +4,7 @@ set -e
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm use 4
nvm use 6
set -x

View File

@ -53,9 +53,9 @@
"classnames": "^2.1.2",
"commonmark": "^0.27.0",
"counterpart": "^0.18.0",
"draft-js": "^0.9.1",
"draft-js-export-html": "^0.5.0",
"draft-js-export-markdown": "^0.2.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",

View File

@ -30,8 +30,9 @@ const customVariables = {
'User Type': 3,
'Chosen Language': 4,
'Instance': 5,
'Homeserver URL': 6,
'Identity Server URL': 7,
'RTE: Uses Richtext Mode': 6,
'Homeserver URL': 7,
'Identity Server URL': 8,
};
function whitelistRedact(whitelist, str) {
@ -148,6 +149,11 @@ class Analytics {
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl));
}
setRichtextMode(state) {
if (this.disabled) return;
this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off');
}
}
if (!global.mxAnalytics) {

View File

@ -55,6 +55,25 @@ function is_multi_line(node) {
return par.firstChild != par.lastChild;
}
import linkifyMatrix from './linkify-matrix';
import * as linkify from 'linkifyjs';
linkifyMatrix(linkify);
// Thieved from draft-js-export-markdown
function escapeMarkdown(s) {
return s.replace(/[*_`]/g, '\\$&');
}
// Replace URLs, room aliases and user IDs with md-escaped URLs
function linkifyMarkdown(s) {
const links = linkify.find(s);
links.forEach((l) => {
// This may replace several instances of `l.value` at once, but that's OK
s = s.replace(l.value, escapeMarkdown(l.value));
});
return s;
}
/**
* Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether
@ -62,7 +81,7 @@ function is_multi_line(node) {
*/
export default class Markdown {
constructor(input) {
this.input = input;
this.input = linkifyMarkdown(input);
const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input);

View File

@ -51,7 +51,8 @@ export const contentStateToHTML = (contentState: ContentState) => {
};
export function htmlToContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html));
const blockArray = convertFromHTML(html).contentBlocks;
return ContentState.createFromBlockArray(blockArray);
}
function unicodeToEmojiUri(str) {
@ -90,7 +91,7 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb
// Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = {
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
@ -119,7 +120,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
export function getScopedMDDecorators(scope: any): CompositeDecorator {
let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
},
component: (props) => (
@ -130,7 +131,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
}));
markdownDecorators.push({
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
},
component: (props) => (
@ -201,10 +202,8 @@ export function selectionStateToTextOffsets(selectionState: SelectionState,
export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();
for (let block of contentBlocks) {
let blockLength = block.getLength();
for (const block of contentBlocks) {
const blockLength = block.getLength();
if (start !== -1 && start < blockLength) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
@ -212,9 +211,8 @@ export function textOffsetsToSelectionState({start, end}: SelectionRange,
});
start = -1;
} else {
start -= blockLength;
start -= blockLength + 1; // +1 to account for newline between blocks
}
if (end !== -1 && end <= blockLength) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
@ -222,10 +220,9 @@ export function textOffsetsToSelectionState({start, end}: SelectionRange,
});
end = -1;
} else {
end -= blockLength;
end -= blockLength + 1; // +1 to account for newline between blocks
}
}
return selectionState;
}
@ -242,7 +239,7 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
const existingEntityKey = block.getEntityAt(start);
if (existingEntityKey) {
// avoid manipulation in case the emoji already has an entity
const entity = Entity.get(existingEntityKey);
const entity = newContentState.getEntity(existingEntityKey);
if (entity && entity.get('type') === 'emoji') {
return;
}
@ -252,7 +249,10 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
.set('anchorOffset', start)
.set('focusOffset', end);
const emojiText = plainText.substring(start, end);
const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText });
newContentState = newContentState.createEntity(
'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }
);
const entityKey = newContentState.getLastCreatedEntityKey();
newContentState = Modifier.replaceText(
newContentState,
selection,

58
src/WidgetUtils.js Normal file
View File

@ -0,0 +1,58 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
* @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
static canUserModifyWidgets(roomId) {
if (!roomId) {
console.warn('No room ID specified');
return false;
}
const client = MatrixClientPeg.get();
if (!client) {
console.warn('User must be be logged in');
return false;
}
const room = client.getRoom(roomId);
if (!room) {
console.warn(`Room ID ${roomId} is not recognised`);
return false;
}
const me = client.credentials.userId;
if (!me) {
console.warn('Failed to get user ID');
return false;
}
const member = room.getMember(me);
if (!member || member.membership !== "join") {
console.warn(`User ${me} is not in room ${roomId}`);
return false;
}
return room.currentState.maySendStateEvent('im.vector.modular.widgets', me);
}
}

View File

@ -71,6 +71,15 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor
let instance = null;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
@ -104,8 +113,20 @@ export default class EmojiProvider extends AutocompleteProvider {
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
// Reinstate original order
completions = _sortBy(_uniq(completions), '_orderBy');
const sorters = [];
// First, sort by score (Infinity if matchedString not in shortname)
sorters.push((c) => score(matchedString, c.shortname));
// If the matchedString is not empty, sort by length of shortname. Example:
// matchedString = ":bookmark"
// completions = [":bookmark:", ":bookmark_tabs:", ...]
if (matchedString.length > 1) {
sorters.push((c) => c.shortname.length);
}
// Finally, sort by original ordering
sorters.push((c) => c._orderBy);
completions = _sortBy(_uniq(completions), sorters);
completions = completions.map((result) => {
const {shortname} = result;
const unicode = shortnameToUnicode(shortname);

View File

@ -23,35 +23,58 @@ import FuzzyMatcher from './FuzzyMatcher';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
import _sortBy from 'lodash/sortBy';
const ROOM_REGEX = /(?=#)(\S*)/g;
let instance = null;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX);
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'roomId', 'aliases'],
keys: ['displayedAlias', 'name'],
});
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get();
// 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);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
this.matcher.setObjects(client.getRooms().filter(room => !!room && !!getDisplayAliasForRoom(room)).map(room => {
this.matcher.setObjects(client.getRooms().filter(
(room) => !!room && !!getDisplayAliasForRoom(room),
).map((room) => {
return {
room: room,
name: room.name,
aliases: room.getAliases(),
displayedAlias: getDisplayAliasForRoom(room),
};
}));
completions = this.matcher.match(command[0]).map(room => {
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
]).map((room) => {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return {
completion: displayAlias,
@ -62,7 +85,9 @@ export default class RoomProvider extends AutocompleteProvider {
),
range,
};
}).filter(completion => !!completion.completion && completion.completion.length > 0).slice(0, 4);
})
.filter((completion) => !!completion.completion && completion.completion.length > 0)
.slice(0, 4);
}
return completions;
}

View File

@ -48,13 +48,21 @@ export default class UserProvider extends AutocompleteProvider {
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
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 [];
}
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
completions = this.matcher.match(command[0]).map((user) => {
const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
return {
completion: displayName,
// 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 ? ': ' : ' ',
href: 'https://matrix.to/#/' + user.userId,
component: (

View File

@ -131,9 +131,6 @@ module.exports = React.createClass({
// the master view we are showing.
view: VIEWS.LOADING,
// a thing to call showScreen with once login completes.
screenAfterLogin: this.props.initialScreenAfterLogin,
// What the LoggedInView would be showing if visible
page_type: null,
@ -147,8 +144,6 @@ module.exports = React.createClass({
collapse_lhs: false,
collapse_rhs: false,
ready: false,
width: 10000,
leftOpacity: 1.0,
middleOpacity: 1.0,
rightOpacity: 1.0,
@ -274,6 +269,15 @@ module.exports = React.createClass({
register_hs_url: paramHs,
});
}
// a thing to call showScreen with once login completes. this is kept
// outside this.state because updating it should never trigger a
// rerender.
this._screenAfterLogin = this.props.initialScreenAfterLogin;
this._windowWidth = 10000;
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
componentDidMount: function() {
@ -294,9 +298,6 @@ module.exports = React.createClass({
linkifyMatrix.onGroupClick = this.onGroupClick;
}
window.addEventListener('resize', this.handleResize);
this.handleResize();
const teamServerConfig = this.props.config.teamServerConfig || {};
Lifecycle.initRtsClient(teamServerConfig.teamServerURL);
@ -312,13 +313,12 @@ module.exports = React.createClass({
// if the user has followed a login or register link, don't reanimate
// the old creds, but rather go straight to the relevant page
const firstScreen = this.state.screenAfterLogin ?
this.state.screenAfterLogin.screen : null;
const firstScreen = this._screenAfterLogin ?
this._screenAfterLogin.screen : null;
if (firstScreen === 'login' ||
firstScreen === 'register' ||
firstScreen === 'forgot_password') {
this.setState({loading: false});
this._showScreenAfterLogin();
return;
}
@ -367,9 +367,9 @@ module.exports = React.createClass({
}
const newState = {
viewUserId: null,
};
Object.assign(newState, state);
this.setState(newState);
};
Object.assign(newState, state);
this.setState(newState);
},
onAction: function(payload) {
@ -992,14 +992,12 @@ module.exports = React.createClass({
_showScreenAfterLogin: function() {
// If screenAfterLogin is set, use that, then null it so that a second login will
// result in view_home_page, _user_settings or _room_directory
if (this.state.screenAfterLogin && this.state.screenAfterLogin.screen) {
if (this._screenAfterLogin && this._screenAfterLogin.screen) {
this.showScreen(
this.state.screenAfterLogin.screen,
this.state.screenAfterLogin.params,
this._screenAfterLogin.screen,
this._screenAfterLogin.params,
);
// XXX: is this necessary? `showScreen` should do it for us.
this.notifyNewScreen(this.state.screenAfterLogin.screen);
this.setState({screenAfterLogin: null});
this._screenAfterLogin = null;
} else if (localStorage && localStorage.getItem('mx_last_room_id')) {
// Before defaulting to directory, show the last viewed room
dis.dispatch({
@ -1276,20 +1274,20 @@ module.exports = React.createClass({
const hideRhsThreshold = 820;
const showRhsThreshold = 820;
if (this.state.width > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
if (this._windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
dis.dispatch({ action: 'hide_left_panel' });
}
if (this.state.width <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
if (this._windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
dis.dispatch({ action: 'show_left_panel' });
}
if (this.state.width > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) {
if (this._windowWidth > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) {
dis.dispatch({ action: 'hide_right_panel' });
}
if (this.state.width <= showRhsThreshold && window.innerWidth > showRhsThreshold) {
if (this._windowWidth <= showRhsThreshold && window.innerWidth > showRhsThreshold) {
dis.dispatch({ action: 'show_right_panel' });
}
this.setState({width: window.innerWidth});
this._windowWidth = window.innerWidth;
},
onRoomCreated: function(roomId) {

View File

@ -101,6 +101,10 @@ const SETTINGS_LABELS = [
id: 'MessageComposerInput.autoReplaceEmoji',
label: 'Automatically replace plain text Emoji',
},
{
id: 'Pill.shouldHidePillAvatar',
label: 'Hide avatars in user and room mentions',
},
/*
{
id: 'useFixedWidthFont',

View File

@ -19,8 +19,11 @@ limitations under the License.
import React from 'react';
import { _t, _tJsx } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
import UserSettingsStore from '../../../UserSettingsStore';
import PlatformPeg from '../../../PlatformPeg';
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/;
@ -306,6 +309,23 @@ module.exports = React.createClass({
}
},
_onLanguageChange: function(newLang) {
if(languageHandler.getCurrentLanguage() !== newLang) {
UserSettingsStore.setLocalSetting('language', newLang);
PlatformPeg.get().reload();
}
},
_renderLanguageSetting: function() {
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
return <div className="mx_Login_language_div">
<LanguageDropdown onOptionChange={this._onLanguageChange}
className="mx_Login_language"
value={languageHandler.getCurrentLanguage()}
/>
</div>;
},
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const LoginHeader = sdk.getComponent("login.LoginHeader");
@ -354,6 +374,7 @@ module.exports = React.createClass({
</a>
{ loginAsGuestJsx }
{ returnToAppJsx }
{ this._renderLanguageSetting() }
<LoginFooter />
</div>
</div>

View File

@ -106,6 +106,16 @@ export default React.createClass({
},
_doUsernameCheck: function() {
// XXX: SPEC-1
// Check if username is valid
// Naive impl copied from https://github.com/matrix-org/matrix-react-sdk/blob/66c3a6d9ca695780eb6b662e242e88323053ff33/src/components/views/login/RegistrationForm.js#L190
if (encodeURIComponent(this.state.username) !== this.state.username) {
this.setState({
usernameError: _t('User names may only contain letters, numbers, dots, hyphens and underscores.'),
});
return Promise.reject();
}
// Check if username is available
return this._matrixClient.isUsernameAvailable(this.state.username).then(
(isAvailable) => {
@ -242,7 +252,7 @@ export default React.createClass({
return (
<BaseDialog className="mx_SetMxIdDialog"
onFinished={this.props.onFinished}
title="To get started, please pick a username!"
title={_t('To get started, please pick a username!')}
>
<div className="mx_Dialog_content">
<div className="mx_SetMxIdDialog_input_group">

View File

@ -0,0 +1,75 @@
import React from 'react';
import PropTypes from 'prop-types';
import url from 'url';
import { _t } from '../../../languageHandler';
export default class AppPermission extends React.Component {
constructor(props) {
super(props);
const curlBase = this.getCurlBase();
this.state = { curlBase: curlBase};
}
// Return string representation of content URL without query parameters
getCurlBase() {
const wurl = url.parse(this.props.url);
let curl;
let curlString;
const searchParams = new URLSearchParams(wurl.search);
if(this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) {
curl = url.parse(searchParams.get('url'));
if(curl) {
curl.search = curl.query = "";
curlString = curl.format();
}
}
if (!curl && wurl) {
wurl.search = wurl.query = "";
curlString = wurl.format();
}
return curlString;
}
isScalarWurl(wurl) {
if(wurl && wurl.hostname && (
wurl.hostname === 'scalar.vector.im' ||
wurl.hostname === 'scalar-staging.riot.im' ||
wurl.hostname === 'scalar-develop.riot.im' ||
wurl.hostname === 'demo.riot.im' ||
wurl.hostname === 'localhost'
)) {
return true;
}
return false;
}
render() {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src='img/warning.svg' alt={_t('Warning!')}/>
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>Do you want to load widget from URL:</span> <span className='mx_AppPermissionWarningTextURL'>{this.state.curlBase}</span>
</div>
<input
className='mx_AppPermissionButton'
type='button'
value={_t('Allow')}
onClick={this.props.onPermissionGranted}
/>
</div>
);
}
}
AppPermission.propTypes = {
url: PropTypes.string.isRequired,
onPermissionGranted: PropTypes.func.isRequired,
};
AppPermission.defaultProps = {
onPermissionGranted: function() {},
};

View File

@ -24,6 +24,10 @@ import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner';
import WidgetUtils from '../../../WidgetUtils';
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const betaHelpMsg = 'This feature is currently experimental and is intended for beta testing only';
@ -37,6 +41,9 @@ export default React.createClass({
name: React.PropTypes.string.isRequired,
room: React.PropTypes.object.isRequired,
type: React.PropTypes.string.isRequired,
// 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: React.PropTypes.bool,
},
getDefaultProps: function() {
@ -46,9 +53,13 @@ export default React.createClass({
},
getInitialState: function() {
const widgetPermissionId = [this.props.room.roomId, encodeURIComponent(this.props.url)].join('_');
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
return {
loading: false,
widgetUrl: this.props.url,
widgetPermissionId: widgetPermissionId,
hasPermissionToLoad: Boolean(hasPermissionToLoad === 'true'),
error: null,
deleting: false,
};
@ -60,6 +71,18 @@ export default React.createClass({
return scalarUrl && this.props.url.startsWith(scalarUrl);
},
isMixedContent: function() {
const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.url);
const childContentProtocol = u.protocol;
if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') {
console.warn("Refusing to load mixed-content app:",
parentContentProtocol, childContentProtocol, window.location, this.props.url);
return true;
}
return false;
},
componentWillMount: function() {
if (!this.isScalarUrl()) {
return;
@ -71,6 +94,7 @@ export default React.createClass({
this._scalarClient = new ScalarAuthClient();
this._scalarClient.getScalarToken().done((token) => {
// Append scalar_token as a query param
this._scalarClient.scalarToken = token;
const u = url.parse(this.props.url);
if (!u.search) {
u.search = "?scalar_token=" + encodeURIComponent(token);
@ -91,6 +115,10 @@ export default React.createClass({
});
},
_canUserModify: function() {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
},
_onEditClick: function(e) {
console.log("Edit widget ID ", this.props.id);
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
@ -100,20 +128,49 @@ export default React.createClass({
}, "mx_IntegrationsManager");
},
/* If user has permission to modify widgets, delete the widget, otherwise revoke access for the widget to load in the user's browser
*/
_onDeleteClick: function() {
console.log("Delete widget %s", this.props.id);
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
this.setState({deleting: false});
});
if (this._canUserModify()) {
console.log("Delete widget %s", this.props.id);
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
this.setState({deleting: false});
});
} else {
console.log("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission();
}
},
// Widget labels to render, depending upon user permissions
// These strings are translated at the point that they are inserted in to the DOM, in the render method
_deleteWidgetLabel() {
if (this._canUserModify()) {
return 'Delete widget';
}
return 'Revoke widget access';
},
/* TODO -- Store permission in account data so that it is persisted across multiple devices */
_grantWidgetPermission() {
console.warn('Granting permission to load widget - ', this.state.widgetUrl);
localStorage.setItem(this.state.widgetPermissionId, true);
this.setState({hasPermissionToLoad: true});
},
_revokeWidgetPermission() {
console.warn('Revoking permission to load widget - ', this.state.widgetUrl);
localStorage.removeItem(this.state.widgetPermissionId);
this.setState({hasPermissionToLoad: false});
},
formatAppTileName: function() {
@ -133,34 +190,66 @@ export default React.createClass({
return <div></div>;
}
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to prgramatically remove the sandbox attribute, but
// this would only be for content hosted on the same origin as the riot client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts allow-presentation";
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
if (this.state.loading) {
appTileBody = (
<div> Loading... </div>
<div className='mx_AppTileBody mx_AppLoading'>
<MessageSpinner msg='Loading...'/>
</div>
);
} else {
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to prgramatically remove the sandbox attribute, but
// this would only be for content hosted on the same origin as the riot client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts";
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
} else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) {
appTileBody = (
<div className="mx_AppTileBody">
<AppWarning
errorMsg="Error - Mixed content"
/>
</div>
);
} else {
appTileBody = (
<div className="mx_AppTileBody">
<iframe
ref="appFrame"
src={safeWidgetUrl}
allowFullScreen="true"
sandbox={sandboxFlags}
></iframe>
</div>
);
}
} else {
appTileBody = (
<div className="mx_AppTileBody">
<iframe ref="appFrame" src={safeWidgetUrl} allowFullScreen="true"
sandbox={sandboxFlags}
></iframe>
<AppPermission
url={this.state.widgetUrl}
onPermissionGranted={this._grantWidgetPermission}
/>
</div>
);
}
// editing is done in scalar
const showEditButton = Boolean(this._scalarClient);
const showEditButton = Boolean(this._scalarClient && this._canUserModify());
const deleteWidgetLabel = this._deleteWidgetLabel();
let deleteIcon = 'img/cancel.svg';
let deleteClasses = 'mx_filterFlipColor mx_AppTileMenuBarWidget';
if(this._canUserModify()) {
deleteIcon = 'img/cancel-red.svg';
deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
}
return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
@ -172,14 +261,18 @@ export default React.createClass({
{showEditButton && <img
src="img/edit.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8" alt="Edit"
width="8" height="8"
alt={_t('Edit')}
title={_t('Edit')}
onClick={this._onEditClick}
/>}
{/* Delete widget */}
<img src="img/cancel.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget"
width="8" height="8" alt={_t("Cancel")}
<img src={deleteIcon}
className={deleteClasses}
width="8" height="8"
alt={_t(deleteWidgetLabel)}
title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick}
/>
</span>

View File

@ -0,0 +1,25 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const AppWarning = (props) => {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src='img/warning.svg' alt={_t('Warning!')}/>
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{props.errorMsg}</span>
</div>
</div>
);
};
AppWarning.propTypes = {
errorMsg: PropTypes.string,
};
AppWarning.defaultProps = {
errorMsg: 'Error',
};
export default AppWarning;

View File

@ -0,0 +1,34 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
module.exports = React.createClass({
displayName: 'MessageSpinner',
render: function() {
const w = this.props.w || 32;
const h = this.props.h || 32;
const imgClass = this.props.imgClassName || "";
const msg = this.props.msg || "Loading...";
return (
<div className="mx_Spinner">
<div className="mx_Spinner_Msg">{msg}</div>&nbsp;
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
</div>
);
},
});

View File

@ -47,6 +47,8 @@ const Pill = React.createClass({
inMessage: PropTypes.bool,
// The room in which this pill is being rendered
room: PropTypes.instanceOf(Room),
// Whether to include an avatar in the pill
shouldShowPillAvatar: PropTypes.bool,
},
getInitialState() {
@ -63,16 +65,15 @@ const Pill = React.createClass({
};
},
componentWillMount() {
this._unmounted = false;
componentWillReceiveProps(nextProps) {
let regex = REGEX_MATRIXTO;
if (this.props.inMessage) {
if (nextProps.inMessage) {
regex = REGEX_LOCAL_MATRIXTO;
}
// Default to the empty array if no match for simplicity
// resource and prefix will be undefined instead of throwing
const matrixToMatch = regex.exec(this.props.url) || [];
const matrixToMatch = regex.exec(nextProps.url) || [];
const resourceId = matrixToMatch[1]; // The room/user ID
const prefix = matrixToMatch[2]; // The first character of prefix
@ -87,7 +88,7 @@ const Pill = React.createClass({
let room;
switch (pillType) {
case Pill.TYPE_USER_MENTION: {
const localMember = this.props.room.getMember(resourceId);
const localMember = nextProps.room.getMember(resourceId);
member = localMember;
if (!localMember) {
member = new RoomMember(null, resourceId);
@ -112,6 +113,11 @@ const Pill = React.createClass({
this.setState({resourceId, pillType, member, room});
},
componentWillMount() {
this._unmounted = false;
this.componentWillReceiveProps(this.props);
},
componentWillUnmount() {
this._unmounted = true;
},
@ -151,7 +157,9 @@ const Pill = React.createClass({
if (member) {
userId = member.userId;
linkText = member.rawDisplayName.replace(' (IRC)', ''); // FIXME when groups are done
avatar = <MemberAvatar member={member} width={16} height={16}/>;
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16}/>;
}
pillClass = 'mx_UserPill';
}
}
@ -160,7 +168,9 @@ const Pill = React.createClass({
const room = this.state.room;
if (room) {
linkText = (room ? getDisplayAliasForRoom(room) : null) || resource;
avatar = <RoomAvatar room={room} width={16} height={16}/>;
if (this.props.shouldShowPillAvatar) {
avatar = <RoomAvatar room={room} width={16} height={16}/>;
}
pillClass = 'mx_RoomPill';
}
}

View File

@ -170,6 +170,7 @@ module.exports = React.createClass({
},
pillifyLinks: function(nodes) {
const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.tagName === "A" && node.getAttribute("href")) {
@ -181,7 +182,12 @@ module.exports = React.createClass({
const pillContainer = document.createElement('span');
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pill = <Pill url={href} inMessage={true} room={room}/>;
const pill = <Pill
url={href}
inMessage={true}
room={room}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
ReactDOM.render(pill, pillContainer);
node.parentNode.replaceChild(pillContainer, node);
@ -269,18 +275,21 @@ module.exports = React.createClass({
},
getEventTileOps: function() {
var self = this;
return {
isWidgetHidden: function() {
return self.state.widgetHidden;
isWidgetHidden: () => {
return this.state.widgetHidden;
},
unhideWidget: function() {
self.setState({ widgetHidden: false });
unhideWidget: () => {
this.setState({ widgetHidden: false });
if (global.localStorage) {
global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId());
global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId());
}
},
getInnerText: () => {
return this.refs.content.innerText;
}
};
},

View File

@ -26,6 +26,7 @@ import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../WidgetUtils';
module.exports = React.createClass({
@ -147,6 +148,15 @@ module.exports = React.createClass({
});
},
_canUserModify: function() {
try {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
} catch(err) {
console.error(err);
return false;
}
},
onClickAddWidget: function(e) {
if (e) {
e.preventDefault();
@ -164,7 +174,7 @@ module.exports = React.createClass({
render: function() {
const apps = this.state.apps.map(
(app, index, arr) => {
return <AppTile
return (<AppTile
key={app.id}
id={app.id}
url={app.url}
@ -173,10 +183,10 @@ module.exports = React.createClass({
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
/>;
/>);
});
const addWidget = this.state.apps && this.state.apps.length < 2 &&
const addWidget = this.state.apps && this.state.apps.length < 2 && this._canUserModify() &&
(<div onClick={this.onClickAddWidget}
role="button"
tabIndex="0"

View File

@ -172,7 +172,7 @@ export default class Autocomplete extends React.Component {
}
hide() {
this.setState({hide: true, selectionOffset: 0});
this.setState({hide: true, selectionOffset: 0, completions: [], completionList: []});
}
forceComplete() {

View File

@ -155,7 +155,9 @@ module.exports = withMatrixClient(React.createClass({
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.mxEvent !== this.props.mxEvent) {
// re-check the sender verification as outgoing events progress through
// the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
this._verifyEvent(nextProps.mxEvent);
}
},
@ -386,6 +388,36 @@ module.exports = withMatrixClient(React.createClass({
});
},
_renderE2EPadlock: function() {
const ev = this.props.mxEvent;
const props = {onClick: this.onCryptoClicked};
if (ev.getContent().msgtype === 'm.bad.encrypted') {
return <E2ePadlockUndecryptable {...props}/>;
} else if (ev.isEncrypted()) {
if (this.state.verified) {
return <E2ePadlockVerified {...props}/>;
} else {
return <E2ePadlockUnverified {...props}/>;
}
} else {
// XXX: if the event is being encrypted (ie eventSendStatus ===
// encrypting), it might be nice to show something other than the
// open padlock?
// if the event is not encrypted, but it's an e2e room, show the
// open padlock
const e2eEnabled = this.props.matrixClient.isRoomEncrypted(ev.getRoomId());
if (e2eEnabled) {
return <E2ePadlockUnencrypted {...props}/>;
}
}
// no padlock needed
return null;
},
render: function() {
var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
var SenderProfile = sdk.getComponent('messages.SenderProfile');
@ -407,7 +439,6 @@ module.exports = withMatrixClient(React.createClass({
throw new Error("Event type not supported");
}
var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId());
var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
@ -485,26 +516,7 @@ module.exports = withMatrixClient(React.createClass({
const editButton = (
<span className="mx_EventTile_editButton" title={ _t("Options") } onClick={this.onEditClicked} />
);
let e2e;
// cosmetic padlocks:
if ((e2eEnabled && this.props.eventSendStatus) || this.props.mxEvent.getType() === 'm.room.encryption') {
e2e = <img style={{ cursor: 'initial', marginLeft: '-1px' }} className="mx_EventTile_e2eIcon" alt={_t("Encrypted by a verified device")} src="img/e2e-verified.svg" width="10" height="12" />;
}
// real padlocks
else if (this.props.mxEvent.isEncrypted() || (e2eEnabled && this.props.eventSendStatus)) {
if (this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted') {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Undecryptable")} src="img/e2e-blocked.svg" width="12" height="12" style={{ marginLeft: "-1px" }} />;
}
else if (this.state.verified == true || (e2eEnabled && this.props.eventSendStatus)) {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Encrypted by a verified device")} src="img/e2e-verified.svg" width="10" height="12"/>;
}
else {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Encrypted by an unverified device")} src="img/e2e-warning.svg" width="15" height="12" style={{ marginLeft: "-2px" }}/>;
}
}
else if (e2eEnabled) {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Unencrypted message")} src="img/e2e-unencrypted.svg" width="12" height="12"/>;
}
const timestamp = this.props.mxEvent.getTs() ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
@ -572,7 +584,7 @@ module.exports = withMatrixClient(React.createClass({
<a href={ permalink } onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ e2e }
{ this._renderE2EPadlock() }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
@ -597,3 +609,39 @@ module.exports.haveTileForEvent = function(e) {
return true;
}
};
function E2ePadlockUndecryptable(props) {
return (
<E2ePadlock alt={_t("Undecryptable")}
src="img/e2e-blocked.svg" width="12" height="12"
style={{ marginLeft: "-1px" }} {...props} />
);
}
function E2ePadlockVerified(props) {
return (
<E2ePadlock alt={_t("Encrypted by a verified device")}
src="img/e2e-verified.svg" width="10" height="12"
{...props} />
);
}
function E2ePadlockUnverified(props) {
return (
<E2ePadlock alt={_t("Encrypted by an unverified device")}
src="img/e2e-warning.svg" width="15" height="12"
style={{ marginLeft: "-2px" }} {...props} />
);
}
function E2ePadlockUnencrypted(props) {
return (
<E2ePadlock alt={_t("Unencrypted message")}
src="img/e2e-unencrypted.svg" width="12" height="12"
{...props} />
);
}
function E2ePadlock(props) {
return <img className="mx_EventTile_e2eIcon" {...props} />;
}

View File

@ -31,6 +31,7 @@ import KeyCode from '../../../KeyCode';
import Modal from '../../../Modal';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Analytics from '../../../Analytics';
import dis from '../../../dispatcher';
import UserSettingsStore from '../../../UserSettingsStore';
@ -50,7 +51,7 @@ const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g')
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
const REGEX_EMOJI_WHITESPACE = new RegExp('(' + asciiRegexp + ')\\s$');
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
@ -97,20 +98,39 @@ export default class MessageComposerInput extends React.Component {
onInputStateChanged: React.PropTypes.func,
};
static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes
if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
return 'toggle-mode';
static getKeyBinding(ev: SyntheticKeyboardEvent): string {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
let ctrlCmdOnly;
if (isMac) {
ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
// Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I
if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) {
// When null is returned, draft-js will NOT preventDefault, allowing dev tools
// to be toggled when the editor is focussed
return null;
// Restrict a subset of key bindings to ONLY having ctrl/meta* pressed and
// importantly NOT having alt, shift, meta/ctrl* pressed. draft-js does not
// handle this in `getDefaultKeyBinding` so we do it ourselves here.
//
// * if macOS, read second option
const ctrlCmdCommand = {
// C-m => Toggles between rich text and markdown modes
[KeyCode.KEY_M]: 'toggle-mode',
[KeyCode.KEY_B]: 'bold',
[KeyCode.KEY_I]: 'italic',
[KeyCode.KEY_U]: 'underline',
[KeyCode.KEY_J]: 'code',
[KeyCode.KEY_O]: 'split-block',
}[ev.keyCode];
if (ctrlCmdCommand) {
if (!ctrlCmdOnly) {
return null;
}
return ctrlCmdCommand;
}
return getDefaultKeyBinding(e);
// Handle keys such as return, left and right arrows etc.
return getDefaultKeyBinding(ev);
}
static getBlockStyle(block: ContentBlock): ?string {
@ -141,6 +161,8 @@ export default class MessageComposerInput extends React.Component {
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false);
Analytics.setRichtextMode(isRichtextEnabled);
this.state = {
// whether we're in rich text or markdown mode
isRichtextEnabled,
@ -165,17 +187,18 @@ export default class MessageComposerInput extends React.Component {
this.client = MatrixClientPeg.get();
}
findLinkEntities(contentBlock, callback) {
findLinkEntities(contentState: ContentState, contentBlock: ContentBlock, callback) {
contentBlock.findEntityRanges(
(character) => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
Entity.get(entityKey).getType() === 'LINK'
contentState.getEntity(entityKey).getType() === 'LINK'
);
}, callback,
);
}
/*
* "Does the right thing" to create an EditorState, based on:
* - whether we've got rich text mode enabled
@ -184,13 +207,19 @@ export default class MessageComposerInput extends React.Component {
createEditorState(richText: boolean, contentState: ?ContentState): EditorState {
const decorators = richText ? RichText.getScopedRTDecorators(this.props) :
RichText.getScopedMDDecorators(this.props);
const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false);
decorators.push({
strategy: this.findLinkEntities.bind(this),
component: (entityProps) => {
const Pill = sdk.getComponent('elements.Pill');
const {url} = Entity.get(entityProps.entityKey).getData();
const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData();
if (Pill.isPillUrl(url)) {
return <Pill url={url} room={this.props.room} offsetKey={entityProps.offsetKey}/>;
return <Pill
url={url}
room={this.props.room}
offsetKey={entityProps.offsetKey}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
}
return (
@ -243,7 +272,8 @@ export default class MessageComposerInput extends React.Component {
// paths for inserting a user pill is not fun
const selection = this.state.editorState.getSelection();
const member = this.props.room.getMember(payload.user_id);
const completion = member ? member.name.replace(' (IRC)', '') : payload.user_id;
const completion = member ?
member.rawDisplayName.replace(' (IRC)', '') : payload.user_id;
this.setDisplayedCompletion({
completion,
selection,
@ -253,10 +283,12 @@ export default class MessageComposerInput extends React.Component {
}
break;
case 'quote': {
let {body, formatted_body} = payload.event.getContent();
formatted_body = formatted_body || escape(body);
if (formatted_body) {
let content = RichText.htmlToContentState(`<blockquote>${formatted_body}</blockquote>`);
/// XXX: Not doing rich-text quoting from formatted-body because draft-js
/// has regressed such that when links are quoted, errors are thrown. See
/// https://github.com/vector-im/riot-web/issues/4756.
let body = escape(payload.text);
if (body) {
let content = RichText.htmlToContentState(`<blockquote>${body}</blockquote>`);
if (!this.state.isRichtextEnabled) {
content = ContentState.createFromText(RichText.stateToMarkdown(content));
}
@ -393,7 +425,7 @@ export default class MessageComposerInput extends React.Component {
const newContentState = Modifier.replaceText(
editorState.getCurrentContent(),
currentSelection.merge({
anchorOffset: currentStartOffset - emojiMatch[0].length,
anchorOffset: currentStartOffset - emojiMatch[1].length - 1,
focusOffset: currentStartOffset,
}),
unicodeEmoji,
@ -427,6 +459,19 @@ export default class MessageComposerInput extends React.Component {
state.editorState = RichText.attachImmutableEntitiesToEmoji(
state.editorState);
// Hide the autocomplete if the cursor location changes but the plaintext
// content stays the same. We don't hide if the pt has changed because the
// autocomplete will probably have different completions to show.
if (
!state.editorState.getSelection().equals(
this.state.editorState.getSelection()
)
&& state.editorState.getCurrentContent().getPlainText() ===
this.state.editorState.getCurrentContent().getPlainText()
) {
this.autocomplete.hide();
}
if (state.editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
} else {
@ -483,6 +528,8 @@ export default class MessageComposerInput extends React.Component {
contentState = ContentState.createFromText(markdown);
}
Analytics.setRichtextMode(enabled);
this.setState({
editorState: this.createEditorState(enabled, contentState),
isRichtextEnabled: enabled,
@ -515,7 +562,8 @@ export default class MessageComposerInput extends React.Component {
newState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH');
} else if (shouldToggleBlockFormat) {
const currentStartOffset = this.state.editorState.getSelection().getStartOffset();
if (currentStartOffset === 0) {
const currentEndOffset = this.state.editorState.getSelection().getEndOffset();
if (currentStartOffset === 0 && currentEndOffset === 0) {
// Toggle current block type (setting it to 'unstyled')
newState = RichUtils.toggleBlockType(this.state.editorState, currentBlockType);
}
@ -713,7 +761,7 @@ export default class MessageComposerInput extends React.Component {
const hasLink = blocks.some((block) => {
return block.getCharacterList().filter((c) => {
const entityKey = c.getEntity();
return entityKey && Entity.get(entityKey).getType() === 'LINK';
return entityKey && contentState.getEntity(entityKey).getType() === 'LINK';
}).size > 0;
});
shouldSendHTML = hasLink;
@ -734,8 +782,8 @@ export default class MessageComposerInput extends React.Component {
const pt = contentState.getBlocksAsArray().map((block) => {
let blockText = block.getText();
let offset = 0;
this.findLinkEntities(block, (start, end) => {
const entity = Entity.get(block.getEntityAt(start));
this.findLinkEntities(contentState, block, (start, end) => {
const entity = contentState.getEntity(block.getEntityAt(start));
if (entity.getType() !== 'LINK') {
return;
}
@ -936,32 +984,27 @@ export default class MessageComposerInput extends React.Component {
}
const {range = null, completion = '', href = null, suffix = ''} = displayedCompletion;
let contentState = activeEditorState.getCurrentContent();
let entityKey;
let mdCompletion;
if (href) {
entityKey = Entity.create('LINK', 'IMMUTABLE', {
contentState = contentState.createEntity('LINK', 'IMMUTABLE', {
url: href,
isCompletion: true,
});
entityKey = contentState.getLastCreatedEntityKey();
}
let selection;
if (range) {
selection = RichText.textOffsetsToSelectionState(
range, activeEditorState.getCurrentContent().getBlocksAsArray(),
range, contentState.getBlocksAsArray(),
);
} else {
selection = activeEditorState.getSelection();
}
let contentState = Modifier.replaceText(
activeEditorState.getCurrentContent(),
selection,
mdCompletion || completion,
null,
entityKey,
);
contentState = Modifier.replaceText(contentState, selection, completion, null, entityKey);
// Move the selection to the end of the block
const afterSelection = contentState.getSelectionAfter();
@ -1047,7 +1090,7 @@ export default class MessageComposerInput extends React.Component {
offset -= sum;
const entityKey = block.getEntityAt(offset);
const entity = entityKey ? Entity.get(entityKey) : null;
const entity = entityKey ? contentState.getEntity(entityKey) : null;
if (entity && entity.getData().isCompletion) {
// This is a completed mention, so do not insert MD link, just text
return text;

View File

@ -134,6 +134,7 @@
"Add phone number": "Add phone number",
"Admin": "Admin",
"Admin tools": "Admin tools",
"Allow": "Allow",
"And %(count)s more...": "And %(count)s more...",
"VoIP": "VoIP",
"Missing Media Permissions, click here to request.": "Missing Media Permissions, click here to request.",
@ -241,6 +242,7 @@
"Decrypt %(text)s": "Decrypt %(text)s",
"Decryption error": "Decryption error",
"Delete": "Delete",
"Delete widget": "Delete widget",
"demote": "demote",
"Deops user with given id": "Deops user with given id",
"Default": "Default",
@ -267,6 +269,7 @@
"Drop here %(toAction)s": "Drop here %(toAction)s",
"Drop here to tag %(section)s": "Drop here to tag %(section)s",
"Ed25519 fingerprint": "Ed25519 fingerprint",
"Edit": "Edit",
"Email": "Email",
"Email address": "Email address",
"Email address (optional)": "Email address (optional)",
@ -460,6 +463,7 @@
"Reason": "Reason",
"Reason: %(reasonText)s": "Reason: %(reasonText)s",
"Revoke Moderator": "Revoke Moderator",
"Revoke widget access": "Revoke widget access",
"Refer a friend to Riot:": "Refer a friend to Riot:",
"Register": "Register",
"rejected": "rejected",
@ -571,6 +575,7 @@
"To configure the room": "To configure the room",
"to demote": "to demote",
"to favourite": "to favourite",
"To get started, please pick a username!": "To get started, please pick a username!",
"To invite users into the room": "To invite users into the room",
"To kick users": "To kick users",
"To link to a room it must have <a>an address</a>.": "To link to a room it must have <a>an address</a>.",
@ -962,5 +967,6 @@
"Edit Group": "Edit Group",
"Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
"Failed to upload image": "Failed to upload image",
"Failed to update group": "Failed to update group"
"Failed to update group": "Failed to update group",
"Hide avatars in user and room mentions": "Hide avatars in user and room mentions"
}

View File

@ -233,6 +233,7 @@
"demote": "demote",
"Deops user with given id": "Deops user with given id",
"Default": "Default",
"Delete widget": "Delete widget",
"Device already verified!": "Device already verified!",
"Device ID": "Device ID",
"Device ID:": "Device ID:",
@ -252,6 +253,7 @@
"Drop here %(toAction)s": "Drop here %(toAction)s",
"Drop here to tag %(section)s": "Drop here to tag %(section)s",
"Ed25519 fingerprint": "Ed25519 fingerprint",
"Edit": "Edit",
"Email": "Email",
"Email address": "Email address",
"Email address (optional)": "Email address (optional)",
@ -421,6 +423,7 @@
"Profile": "Profile",
"Reason": "Reason",
"Revoke Moderator": "Revoke Moderator",
"Revoke widget access": "Revoke widget access",
"Refer a friend to Riot:": "Refer a friend to Riot:",
"Register": "Register",
"rejected": "rejected",