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
This commit is contained in:
commit
b19c1010aa
25 changed files with 694 additions and 168 deletions
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
75
src/components/views/elements/AppPermission.js
Normal file
75
src/components/views/elements/AppPermission.js
Normal 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() {},
|
||||
};
|
|
@ -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>
|
||||
|
|
25
src/components/views/elements/AppWarning.js
Normal file
25
src/components/views/elements/AppWarning.js
Normal 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;
|
34
src/components/views/elements/MessageSpinner.js
Normal file
34
src/components/views/elements/MessageSpinner.js
Normal 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>
|
||||
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue