diff --git a/package.json b/package.json index ffd701a233..b4e1af9f0a 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,6 @@ "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", "mocha": "^5.0.5", - "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^5.0.7", diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 813f0ed87e..27c4f40669 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -32,6 +32,7 @@ import Modal from './Modal'; import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; +import IdentityAuthClient from './IdentityAuthClient'; interface MatrixClientCreds { homeserverUrl: string, @@ -219,6 +220,7 @@ class MatrixClientPeg { fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'), verificationMethods: [verificationMethods.SAS], unstableClientRelationAggregation: true, + identityServer: new IdentityAuthClient(), }; this.matrixClient = createMatrixClient(opts); diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index 5db8b2365f..145203136a 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -15,12 +15,13 @@ limitations under the License. */ const React = require("react"); +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; const sdk = require('../../../index'); const MatrixClientPeg = require("../../../MatrixClientPeg"); -module.exports = React.createClass({ +module.exports = createReactClass({ displayName: 'EncryptedEventDialog', propTypes: { diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 529780c121..0fd412935a 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -17,6 +17,7 @@ limitations under the License. import FileSaver from 'file-saver'; import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; import { MatrixClient } from 'matrix-js-sdk'; @@ -26,7 +27,7 @@ import sdk from '../../../index'; const PHASE_EDIT = 1; const PHASE_EXPORTING = 2; -export default React.createClass({ +export default createReactClass({ displayName: 'ExportE2eKeysDialog', propTypes: { diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 5181b6da2f..17f3bba117 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; import { MatrixClient } from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; @@ -37,7 +38,7 @@ function readFileAsArrayBuffer(file) { const PHASE_EDIT = 1; const PHASE_IMPORTING = 2; -export default React.createClass({ +export default createReactClass({ displayName: 'ImportE2eKeysDialog', propTypes: { diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 9ceff69467..e36763591e 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; import { scorePassword } from '../../../../utils/PasswordScorer'; @@ -48,7 +49,7 @@ function selectText(target) { * Walks the user through the process of creating an e2e key backup * on the server. */ -export default React.createClass({ +export default createReactClass({ getInitialState: function() { return { phase: PHASE_PASSPHRASE, diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 6cb5a278fd..ac2181f1f2 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -19,6 +19,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; import { _t, _td } from '../../../languageHandler'; import sdk from '../../../index'; @@ -39,7 +40,7 @@ const addressTypeName = { }; -module.exports = React.createClass({ +module.exports = createReactClass({ displayName: "AddressPickerDialog", propTypes: { diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.js index d4b073eb01..3d10752ff8 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.js @@ -16,12 +16,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import {SettingLevel} from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore"; -export default React.createClass({ +export default createReactClass({ propTypes: { unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ] onInviteAnyways: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index ee838b9825..65b89d1631 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import FocusTrap from 'focus-trap-react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -32,7 +33,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg'; * Includes a div for the title, and a keypress handler which cancels the * dialog on escape. */ -export default React.createClass({ +export default createReactClass({ displayName: 'BaseDialog', propTypes: { diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js index a967b5df9a..c606706ed2 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -15,13 +15,14 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; /* * A dialog for confirming a redaction. */ -export default React.createClass({ +export default createReactClass({ displayName: 'ConfirmRedactDialog', render: function() { diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 4848e468e9..4d33b2b500 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../../index'; @@ -29,7 +30,7 @@ import { GroupMemberType } from '../../../groups'; * to make it obvious what is going to happen. * Also tweaks the style for 'dangerous' actions (albeit only with colour) */ -export default React.createClass({ +export default createReactClass({ displayName: 'ConfirmUserActionDialog', propTypes: { // matrix-js-sdk (room) member object. Supply either this or 'groupMember' diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 882d323449..11f4c21366 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -15,13 +15,14 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import { _t } from '../../../languageHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; -export default React.createClass({ +export default createReactClass({ displayName: 'CreateGroupDialog', propTypes: { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index 3212e53c05..e1da9f841d 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -15,12 +15,13 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import { _t } from '../../../languageHandler'; -export default React.createClass({ +export default createReactClass({ displayName: 'CreateRoomDialog', propTypes: { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index a055f07629..f6db0a14a5 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -26,11 +26,12 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -export default React.createClass({ +export default createReactClass({ displayName: 'ErrorDialog', propTypes: { title: PropTypes.string, diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js index d01b737309..c54da480e6 100644 --- a/src/components/views/dialogs/InfoDialog.js +++ b/src/components/views/dialogs/InfoDialog.js @@ -17,12 +17,13 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import classNames from "classnames"; -export default React.createClass({ +export default createReactClass({ displayName: 'InfoDialog', propTypes: { className: PropTypes.string, diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index b068428bed..0b658bad81 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; @@ -23,7 +24,7 @@ import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; -export default React.createClass({ +export default createReactClass({ displayName: 'InteractiveAuthDialog', propTypes: { diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index b9b64a69d2..a10c25a0fb 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -16,6 +16,7 @@ limitations under the License. import Modal from '../../../Modal'; import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; @@ -29,7 +30,7 @@ import { _t, _td } from '../../../languageHandler'; * should not, and `undefined` if the dialog is cancelled. (In other words: * truthy: do the key share. falsy: don't share the keys). */ -export default React.createClass({ +export default createReactClass({ propTypes: { matrixClient: PropTypes.object.isRequired, userId: PropTypes.string.isRequired, diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 4d0defadc2..4d2a699898 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -16,11 +16,12 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -export default React.createClass({ +export default createReactClass({ displayName: 'QuestionDialog', propTypes: { title: PropTypes.string, diff --git a/src/components/views/dialogs/RoomUpgradeDialog.js b/src/components/views/dialogs/RoomUpgradeDialog.js index 45c242fea5..6900ac6fe8 100644 --- a/src/components/views/dialogs/RoomUpgradeDialog.js +++ b/src/components/views/dialogs/RoomUpgradeDialog.js @@ -15,13 +15,14 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -export default React.createClass({ +export default createReactClass({ displayName: 'RoomUpgradeDialog', propTypes: { diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index f7e117b31b..b9f6e77222 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; @@ -23,7 +24,7 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -export default React.createClass({ +export default createReactClass({ displayName: 'SessionRestoreErrorDialog', propTypes: { diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index e643ddbc34..88baa5fd3e 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import Email from '../../../email'; @@ -29,7 +30,7 @@ import Modal from '../../../Modal'; * * On success, `onFinished(true)` is called. */ -export default React.createClass({ +export default createReactClass({ displayName: 'SetEmailDialog', propTypes: { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index dfaff52278..3bc6f5597e 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -17,6 +17,7 @@ limitations under the License. import Promise from 'bluebird'; import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; @@ -34,7 +35,7 @@ const USERNAME_CHECK_DEBOUNCE_MS = 250; * * On success, `onFinished(true, newDisplayName)` is called. */ -export default React.createClass({ +export default createReactClass({ displayName: 'SetMxIdDialog', propTypes: { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js index 0ec933b59f..0fe65aaca3 100644 --- a/src/components/views/dialogs/SetPasswordDialog.js +++ b/src/components/views/dialogs/SetPasswordDialog.js @@ -17,6 +17,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -62,7 +63,7 @@ const WarmFuzzy = function(props) { * * On success, `onFinished()` when finished */ -export default React.createClass({ +export default createReactClass({ displayName: 'SetPasswordDialog', propTypes: { onFinished: PropTypes.func.isRequired, diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index f28b16ef6f..3ce32ef4ec 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -15,10 +15,11 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; -export default React.createClass({ +export default createReactClass({ displayName: 'TextInputDialog', propTypes: { title: PropTypes.string, diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js index 09b967c72f..e7522e971d 100644 --- a/src/components/views/dialogs/UnknownDeviceDialog.js +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -16,11 +16,10 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import GeminiScrollbar from 'react-gemini-scrollbar'; -import Resend from '../../../Resend'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import { markAllDevicesKnown } from '../../../cryptodevices'; @@ -67,7 +66,7 @@ UnknownDeviceList.propTypes = { }; -export default React.createClass({ +export default createReactClass({ displayName: 'UnknownDeviceDialog', propTypes: { diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 0f390a02c9..172a3ed9ea 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; import Modal from '../../../../Modal'; @@ -29,7 +30,7 @@ const RESTORE_TYPE_RECOVERYKEY = 1; /** * Dialog for restoring e2e keys from a backup and the user's recovery key */ -export default React.createClass({ +export default createReactClass({ getInitialState: function() { return { backupInfo: null, diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index e4179d9c3b..662167b714 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -25,6 +25,11 @@ import {autoCompleteCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; import {Room} from 'matrix-js-sdk'; import TypingStore from "../../../stores/TypingStore"; +import EMOJIBASE from 'emojibase-data/en/compact.json'; +import SettingsStore from "../../../settings/SettingsStore"; +import EMOTICON_REGEX from 'emojibase-regex/emoticon'; + +const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const IS_MAC = navigator.platform.indexOf("Mac") !== -1; @@ -70,6 +75,35 @@ export default class BasicMessageEditor extends React.Component { this._modifiedFlag = false; } + _replaceEmoticon = (caret, inputType, diff) => { + const {model} = this.props; + const range = model.startRange(caret); + // expand range max 8 characters backwards from caret, + // as a space to look for an emoticon + let n = 8; + range.expandBackwardsWhile((index, offset) => { + const part = model.parts[index]; + n -= 1; + return n >= 0 && (part.type === "plain" || part.type === "pill-candidate"); + }); + const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text); + if (emoticonMatch) { + const query = emoticonMatch[1].toLowerCase().replace("-", ""); + const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false); + if (data) { + const hasPrecedingSpace = emoticonMatch[0][0] === " "; + // we need the range to only comprise of the emoticon + // because we'll replace the whole range with an emoji, + // so move the start forward to the start of the emoticon. + // Take + 1 because index is reported without the possible preceding space. + range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); + // this returns the amount of added/removed characters during the replace + // so the caret position can be adjusted. + return range.replace([this.props.model.partCreator.plain(data.unicode + " ")]); + } + } + } + _updateEditorState = (caret, inputType, diff) => { renderModel(this._editorRef, this.props.model); if (caret) { @@ -262,6 +296,9 @@ export default class BasicMessageEditor extends React.Component { componentDidMount() { const model = this.props.model; model.setUpdateCallback(this._updateEditorState); + if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { + model.setTransformCallback(this._replaceEmoticon); + } const partCreator = model.partCreator; // TODO: does this allow us to get rid of EditorStateTransfer? // not really, but we could not serialize the parts, and just change the autoCompleter diff --git a/src/components/views/settings/SetIdServer.js b/src/components/views/settings/SetIdServer.js index e7aa22527d..55dc3b6e94 100644 --- a/src/components/views/settings/SetIdServer.js +++ b/src/components/views/settings/SetIdServer.js @@ -16,6 +16,7 @@ limitations under the License. import url from 'url'; import React from 'react'; +import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; import sdk from '../../../index'; import MatrixClientPeg from "../../../MatrixClientPeg"; @@ -55,6 +56,12 @@ async function checkIdentityServerUrl(u) { } export default class SetIdServer extends React.Component { + static propTypes = { + // Whether or not the ID server is missing terms. This affects the text + // shown to the user. + missingTerms: PropTypes.bool, + }; + constructor() { super(); @@ -153,31 +160,17 @@ export default class SetIdServer extends React.Component { // Double check that the identity server even has terms of service. const terms = await MatrixClientPeg.get().getTerms(SERVICE_TYPES.IS, fullUrl); if (!terms || !terms["policies"] || Object.keys(terms["policies"]).length <= 0) { - const QuestionDialog = sdk.getComponent("views.dialogs.QuestionDialog"); - Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, { - title: _t("Identity server has no terms of service"), - description: ( -
- - {_t("The identity server you have chosen does not have any terms of service.")} - - -  {_t("Only continue if you trust the owner of the server.")} - -
- ), - button: _t("Continue"), - onFinished: async (confirmed) => { - if (!confirmed) return; - this._saveIdServer(fullUrl); - }, - }); + this._showNoTermsWarning(fullUrl); return; } this._saveIdServer(fullUrl); } catch (e) { console.error(e); + if (e.cors === "rejected" || e.httpStatus === 404) { + this._showNoTermsWarning(fullUrl); + return; + } errStr = _t("Terms of service not accepted or the identity server is invalid."); } } @@ -190,6 +183,28 @@ export default class SetIdServer extends React.Component { }); }; + _showNoTermsWarning(fullUrl) { + const QuestionDialog = sdk.getComponent("views.dialogs.QuestionDialog"); + Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, { + title: _t("Identity server has no terms of service"), + description: ( +
+ + {_t("The identity server you have chosen does not have any terms of service.")} + + +  {_t("Only continue if you trust the owner of the server.")} + +
+ ), + button: _t("Continue"), + onFinished: async (confirmed) => { + if (!confirmed) return; + this._saveIdServer(fullUrl); + }, + }); + } + _onDisconnectClicked = async () => { this.setState({disconnectBusy: true}); try { @@ -266,6 +281,13 @@ export default class SetIdServer extends React.Component { {}, { server: sub => {abbreviateUrl(idServerUrl)} }, ); + if (this.props.missingTerms) { + bodyText = _t( + "If you don't want to use to discover and be discoverable by existing " + + "contacts you know, enter another identity server below.", + {}, {server: sub => {abbreviateUrl(idServerUrl)}}, + ); + } } else { sectionTitle = _t("Identity Server"); bodyText = _t( @@ -278,16 +300,25 @@ export default class SetIdServer extends React.Component { let discoSection; if (idServerUrl) { let discoButtonContent = _t("Disconnect"); + let discoBodyText = _t( + "Disconnecting from your identity server will mean you " + + "won't be discoverable by other users and you won't be " + + "able to invite others by email or phone.", + ); + if (this.props.missingTerms) { + discoBodyText = _t( + "Using an identity server is optional. If you choose not to " + + "use an identity server, you won't be discoverable by other users " + + "and you won't be able to invite others by email or phone.", + ); + discoButtonContent = _t("Do not use an identity server"); + } if (this.state.disconnectBusy) { const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); discoButtonContent = ; } discoSection =
- {_t( - "Disconnecting from your identity server will mean you " + - "won't be discoverable by other users and you won't be " + - "able to invite others by email or phone.", - )} + {discoBodyText} {discoButtonContent} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index 3d78afd222..9c37730fc5 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -249,6 +249,8 @@ export default class GeneralUserSettingsTab extends React.Component { } _renderDiscoverySection() { + const SetIdServer = sdk.getComponent("views.settings.SetIdServer"); + if (this.state.requiredPolicyInfo.hasTerms) { const InlineTermsAgreement = sdk.getComponent("views.terms.InlineTermsAgreement"); const intro = @@ -258,17 +260,22 @@ export default class GeneralUserSettingsTab extends React.Component { {serverName: this.state.idServerName}, )} ; - return ; + return ( +
+ + { /* has its own heading as it includes the current ID server */ } + +
+ ); } const EmailAddresses = sdk.getComponent("views.settings.discovery.EmailAddresses"); const PhoneNumbers = sdk.getComponent("views.settings.discovery.PhoneNumbers"); - const SetIdServer = sdk.getComponent("views.settings.SetIdServer"); const threepidSection = this.state.haveIdServer ?
{_t("Email addresses")} diff --git a/src/editor/model.js b/src/editor/model.js index 2f1e5218d8..9d129afa69 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -16,6 +16,24 @@ limitations under the License. */ import {diffAtCaret, diffDeletion} from "./diff"; +import DocumentPosition from "./position"; +import Range from "./range"; + +/** + * @callback ModelCallback + * @param {DocumentPosition?} caretPosition the position where the caret should be position + * @param {string?} inputType the inputType of the DOM input event + * @param {object?} diff an object with `removed` and `added` strings + */ + + /** + * @callback TransformCallback + * @param {DocumentPosition?} caretPosition the position where the caret should be position + * @param {string?} inputType the inputType of the DOM input event + * @param {object?} diff an object with `removed` and `added` strings + * @return {Number?} addedLen how many characters were added/removed (-) before the caret during the transformation step. + * This is used to adjust the caret position. + */ export default class EditorModel { constructor(parts, partCreator, updateCallback = null) { @@ -24,9 +42,26 @@ export default class EditorModel { this._activePartIdx = null; this._autoComplete = null; this._autoCompletePartIdx = null; + this._transformCallback = null; this.setUpdateCallback(updateCallback); + this._updateInProgress = false; } + /** + * Set a callback for the transformation step. + * While processing an update, right before calling the update callback, + * a transform callback can be called, which serves to do modifications + * on the model that can span multiple parts. Also see `startRange()`. + * @param {TransformCallback} transformCallback + */ + setTransformCallback(transformCallback) { + this._transformCallback = transformCallback; + } + + /** + * Set a callback for rerendering the model after it has been updated. + * @param {ModelCallback} updateCallback + */ setUpdateCallback(updateCallback) { this._updateCallback = updateCallback; } @@ -131,6 +166,7 @@ export default class EditorModel { } update(newValue, inputType, caret) { + this._updateInProgress = true; const diff = this._diff(newValue, inputType, caret); const position = this.positionForOffset(diff.at, caret.atNodeEnd); let removedOffsetDecrease = 0; @@ -145,11 +181,21 @@ export default class EditorModel { } this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; - const newPosition = this.positionForOffset(caretOffset, true); + let newPosition = this.positionForOffset(caretOffset, true); this._setActivePart(newPosition, canOpenAutoComplete); + if (this._transformCallback) { + const transformAddedLen = this._transform(newPosition, inputType, diff); + newPosition = this.positionForOffset(caretOffset + transformAddedLen, true); + } + this._updateInProgress = false; this._updateCallback(newPosition, inputType, diff); } + _transform(newPosition, inputType, diff) { + const result = this._transformCallback(newPosition, inputType, diff); + return Number.isFinite(result) ? result : 0; + } + _setActivePart(pos, canOpenAutoComplete) { const {index} = pos; const part = this._parts[index]; @@ -197,7 +243,7 @@ export default class EditorModel { this._updateCallback(pos); } - _mergeAdjacentParts(docPos) { + _mergeAdjacentParts() { let prevPart; for (let i = 0; i < this._parts.length; ++i) { let part = this._parts[i]; @@ -339,19 +385,39 @@ export default class EditorModel { return new DocumentPosition(index, totalOffset - currentOffset); } -} -class DocumentPosition { - constructor(index, offset) { - this._index = index; - this._offset = offset; + /** + * Starts a range, which can span across multiple parts, to find and replace text. + * @param {DocumentPosition} position where to start the range + * @return {Range} + */ + startRange(position) { + return new Range(this, position); } - get index() { - return this._index; - } - - get offset() { - return this._offset; + // called from Range.replace + replaceRange(startPosition, endPosition, parts) { + const newStartPartIndex = this._splitAt(startPosition); + const idxDiff = newStartPartIndex - startPosition.index; + // if both position are in the same part, and we split it at start position, + // the offset of the end position needs to be decreased by the offset of the start position + const removedOffset = startPosition.index === endPosition.index ? startPosition.offset : 0; + const adjustedEndPosition = new DocumentPosition( + endPosition.index + idxDiff, + endPosition.offset - removedOffset, + ); + const newEndPartIndex = this._splitAt(adjustedEndPosition); + for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) { + this._removePart(i); + } + let insertIdx = newStartPartIndex; + for (const part of parts) { + this._insertPart(insertIdx, part); + insertIdx += 1; + } + this._mergeAdjacentParts(); + if (!this._updateInProgress) { + this._updateCallback(); + } } } diff --git a/src/editor/position.js b/src/editor/position.js new file mode 100644 index 0000000000..5dcb31fe65 --- /dev/null +++ b/src/editor/position.js @@ -0,0 +1,107 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +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. +*/ + +export default class DocumentPosition { + constructor(index, offset) { + this._index = index; + this._offset = offset; + } + + get index() { + return this._index; + } + + get offset() { + return this._offset; + } + + compare(otherPos) { + if (this._index === otherPos._index) { + return this._offset - otherPos._offset; + } else { + return this._index - otherPos._index; + } + } + + iteratePartsBetween(other, model, callback) { + if (this.index === -1 || other.index === -1) { + return; + } + const [startPos, endPos] = this.compare(other) < 0 ? [this, other] : [other, this]; + if (startPos.index === endPos.index) { + callback(model.parts[this.index], startPos.offset, endPos.offset); + } else { + const firstPart = model.parts[startPos.index]; + callback(firstPart, startPos.offset, firstPart.text.length); + for (let i = startPos.index + 1; i < endPos.index; ++i) { + const part = model.parts[i]; + callback(part, 0, part.text.length); + } + const lastPart = model.parts[endPos.index]; + callback(lastPart, 0, endPos.offset); + } + } + + forwardsWhile(model, predicate) { + if (this.index === -1) { + return this; + } + + let {index, offset} = this; + const {parts} = model; + while (index < parts.length) { + const part = parts[index]; + while (offset < part.text.length) { + if (!predicate(index, offset, part)) { + return new DocumentPosition(index, offset); + } + offset += 1; + } + // end reached + if (index === (parts.length - 1)) { + return new DocumentPosition(index, offset); + } else { + index += 1; + offset = 0; + } + } + } + + backwardsWhile(model, predicate) { + if (this.index === -1) { + return this; + } + + let {index, offset} = this; + const parts = model.parts; + while (index >= 0) { + const part = parts[index]; + while (offset > 0) { + if (!predicate(index, offset - 1, part)) { + return new DocumentPosition(index, offset); + } + offset -= 1; + } + // start reached + if (index === 0) { + return new DocumentPosition(index, offset); + } else { + index -= 1; + offset = parts[index].text.length; + } + } + } +} diff --git a/src/editor/range.js b/src/editor/range.js new file mode 100644 index 0000000000..e2ecc5d12b --- /dev/null +++ b/src/editor/range.js @@ -0,0 +1,53 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +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. +*/ + +export default class Range { + constructor(model, startPosition, endPosition = startPosition) { + this._model = model; + this._start = startPosition; + this._end = endPosition; + } + + moveStart(delta) { + this._start = this._start.forwardsWhile(this._model, () => { + delta -= 1; + return delta >= 0; + }); + } + + expandBackwardsWhile(predicate) { + this._start = this._start.backwardsWhile(this._model, predicate); + } + + get text() { + let text = ""; + this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => { + const t = part.text.substring(startIdx, endIdx); + text = text + t; + }); + return text; + } + + replace(parts) { + const newLength = parts.reduce((sum, part) => sum + part.text.length, 0); + let oldLength = 0; + this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => { + oldLength += endIdx - startIdx; + }); + this._model.replaceRange(this._start, this._end, parts); + return newLength - oldLength; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 53429fe735..62b6467b94 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -550,19 +550,22 @@ "Not a valid Identity Server (status code %(code)s)": "Not a valid Identity Server (status code %(code)s)", "Could not connect to Identity Server": "Could not connect to Identity Server", "Checking server": "Checking server", + "Terms of service not accepted or the identity server is invalid.": "Terms of service not accepted or the identity server is invalid.", "Identity server has no terms of service": "Identity server has no terms of service", "The identity server you have chosen does not have any terms of service.": "The identity server you have chosen does not have any terms of service.", "Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.", - "Terms of service not accepted or the identity server is invalid.": "Terms of service not accepted or the identity server is invalid.", "You are currently sharing email addresses or phone numbers on the identity server . You will need to reconnect to to stop sharing them.": "You are currently sharing email addresses or phone numbers on the identity server . You will need to reconnect to to stop sharing them.", "Disconnect from the identity server ?": "Disconnect from the identity server ?", "Disconnect Identity Server": "Disconnect Identity Server", "Disconnect": "Disconnect", "Identity Server (%(server)s)": "Identity Server (%(server)s)", "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.", + "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.": "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.", "Identity Server": "Identity Server", "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.", "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.", + "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.", + "Do not use an identity server": "Do not use an identity server", "Enter a new identity server": "Enter a new identity server", "Change": "Change", "Failed to update integration manager": "Failed to update integration manager", diff --git a/src/languageHandler.js b/src/languageHandler.js index 474cd2b3cd..179bb2d1d0 100644 --- a/src/languageHandler.js +++ b/src/languageHandler.js @@ -179,12 +179,12 @@ export function replaceByRegexes(text, mapping) { for (const regexpString in mapping) { // TODO: Cache regexps - const regexp = new RegExp(regexpString); + const regexp = new RegExp(regexpString, "g"); // Loop over what output we have so far and perform replacements // We look for matches: if we find one, we get three parts: everything before the match, the replaced part, // and everything after the match. Insert all three into the output. We need to do this because we can insert objects. - // Otherwise there would be no need for the splitting and we could do simple replcement. + // Otherwise there would be no need for the splitting and we could do simple replacement. let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it for (const outputIndex in output) { const inputText = output[outputIndex]; @@ -192,44 +192,62 @@ export function replaceByRegexes(text, mapping) { continue; } - const match = inputText.match(regexp); - if (!match) { - continue; - } + // process every match in the string + // starting with the first + let match = regexp.exec(inputText); + + if (!match) continue; matchFoundSomewhere = true; - const capturedGroups = match.slice(2); - - // The textual part before the match + // The textual part before the first match const head = inputText.substr(0, match.index); - // The textual part after the match - const tail = inputText.substr(match.index + match[0].length); + const parts = []; + // keep track of prevMatch + let prevMatch; + while (match) { + // store prevMatch + prevMatch = match; + const capturedGroups = match.slice(2); - let replaced; - // If substitution is a function, call it - if (mapping[regexpString] instanceof Function) { - replaced = mapping[regexpString].apply(null, capturedGroups); - } else { - replaced = mapping[regexpString]; + let replaced; + // If substitution is a function, call it + if (mapping[regexpString] instanceof Function) { + replaced = mapping[regexpString].apply(null, capturedGroups); + } else { + replaced = mapping[regexpString]; + } + + if (typeof replaced === 'object') { + shouldWrapInSpan = true; + } + + // Here we also need to check that it actually is a string before comparing against one + // The head and tail are always strings + if (typeof replaced !== 'string' || replaced !== '') { + parts.push(replaced); + } + + // try the next match + match = regexp.exec(inputText); + + // add the text between prevMatch and this one + // or the end of the string if prevMatch is the last match + let tail; + if (match) { + const startIndex = prevMatch.index + prevMatch[0].length; + tail = inputText.substr(startIndex, match.index - startIndex); + } else { + tail = inputText.substr(prevMatch.index + prevMatch[0].length); + } + if (tail) { + parts.push(tail); + } } - if (typeof replaced === 'object') { - shouldWrapInSpan = true; - } - - output.splice(outputIndex, 1); // Remove old element - // Insert in reverse order as splice does insert-before and this way we get the final order correct - if (tail !== '') { - output.splice(outputIndex, 0, tail); - } - - // Here we also need to check that it actually is a string before comparing against one - // The head and tail are always strings - if (typeof replaced !== 'string' || replaced !== '') { - output.splice(outputIndex, 0, replaced); - } + // remove the old element at the same time + output.splice(outputIndex, 1, ...parts); if (head !== '') { // Don't push empty nodes, they are of no use output.splice(outputIndex, 0, head); diff --git a/src/rageshake/rageshake.js b/src/rageshake/rageshake.js index 6366392c04..1acce0600c 100644 --- a/src/rageshake/rageshake.js +++ b/src/rageshake/rageshake.js @@ -88,7 +88,9 @@ class ConsoleLogger { // run. // Example line: // 2017-01-18T11:23:53.214Z W Failed to set badge count - const line = `${ts} ${level} ${args.join(' ')}\n`; + let line = `${ts} ${level} ${args.join(' ')}\n`; + // Do some cleanup + line = line.replace(/token=[a-zA-Z0-9-]+/gm, 'token=xxxxx'); // Using + really is the quickest way in JS // http://jsperf.com/concat-vs-plus-vs-join this.logs += line; diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 138681457c..58b1590cf1 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -19,7 +19,7 @@ import SettingsStore from "../../../src/settings/SettingsStore"; const React = require('react'); const ReactDOM = require("react-dom"); import PropTypes from "prop-types"; -const TestUtils = require('react-addons-test-utils'); +const TestUtils = require('react-dom/test-utils'); const expect = require('expect'); import sinon from 'sinon'; import { EventEmitter } from "events"; diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index 95f76dfd3e..b14ea7c242 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -18,7 +18,7 @@ import expect from 'expect'; import Promise from 'bluebird'; import React from 'react'; import ReactDOM from 'react-dom'; -import ReactTestUtils from 'react-addons-test-utils'; +import ReactTestUtils from 'react-dom/test-utils'; import sinon from 'sinon'; import MatrixReactTestUtils from 'matrix-react-test-utils'; diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js index f3749b850f..d1e112735d 100644 --- a/test/components/views/elements/MemberEventListSummary-test.js +++ b/test/components/views/elements/MemberEventListSummary-test.js @@ -1,6 +1,6 @@ import expect from 'expect'; import React from 'react'; -import ReactTestUtils from 'react-addons-test-utils'; +import ReactTestUtils from 'react-dom/test-utils'; import sdk from 'matrix-react-sdk'; import * as languageHandler from '../../../../src/languageHandler'; import * as testUtils from '../../../test-utils'; diff --git a/test/components/views/rooms/MemberList-test.js b/test/components/views/rooms/MemberList-test.js index b9d96635a2..9a1439c2f7 100644 --- a/test/components/views/rooms/MemberList-test.js +++ b/test/components/views/rooms/MemberList-test.js @@ -1,5 +1,5 @@ import React from 'react'; -import ReactTestUtils from 'react-addons-test-utils'; +import ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; import expect from 'expect'; import lolex from 'lolex'; diff --git a/test/components/views/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js index ed07c0f233..1105a4af17 100644 --- a/test/components/views/rooms/MessageComposerInput-test.js +++ b/test/components/views/rooms/MessageComposerInput-test.js @@ -1,5 +1,5 @@ import React from 'react'; -import ReactTestUtils from 'react-addons-test-utils'; +import ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; import expect from 'expect'; import sinon from 'sinon'; @@ -8,7 +8,6 @@ import * as testUtils from '../../../test-utils'; import sdk from 'matrix-react-sdk'; const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput'); import MatrixClientPeg from '../../../../src/MatrixClientPeg'; -import RoomMember from 'matrix-js-sdk'; function addTextToDraft(text) { const components = document.getElementsByClassName('public-DraftEditor-content'); @@ -301,4 +300,4 @@ xdescribe('MessageComposerInput', () => { expect(spy.args[0][1].body).toEqual('[Click here](https://some.lovely.url)'); expect(spy.args[0][1].formatted_body).toEqual('Click here'); }); -}); \ No newline at end of file +}); diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index 754367cd23..68168fcf29 100644 --- a/test/components/views/rooms/RoomList-test.js +++ b/test/components/views/rooms/RoomList-test.js @@ -1,5 +1,5 @@ import React from 'react'; -import ReactTestUtils from 'react-addons-test-utils'; +import ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; import expect from 'expect'; import lolex from 'lolex'; diff --git a/test/editor/position-test.js b/test/editor/position-test.js new file mode 100644 index 0000000000..7ac4284c60 --- /dev/null +++ b/test/editor/position-test.js @@ -0,0 +1,80 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +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 expect from 'expect'; +import EditorModel from "../../src/editor/model"; +import {createPartCreator} from "./mock"; + +function createRenderer() { + const render = (c) => { + render.caret = c; + render.count += 1; + }; + render.count = 0; + render.caret = null; + return render; +} + +describe('editor/position', function() { + it('move first position backward in empty model', function() { + const model = new EditorModel([], createPartCreator(), createRenderer()); + const pos = model.positionForOffset(0, true); + const pos2 = pos.backwardsWhile(model, () => true); + expect(pos).toBe(pos2); + }); + it('move first position forwards in empty model', function() { + const model = new EditorModel([], createPartCreator(), createRenderer()); + const pos = model.positionForOffset(0, true); + const pos2 = pos.forwardsWhile(() => true); + expect(pos).toBe(pos2); + }); + it('move forwards within one part', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello")], pc, createRenderer()); + const pos = model.positionForOffset(1); + let n = 3; + const pos2 = pos.forwardsWhile(model, () => { n -= 1; return n >= 0; }); + expect(pos2.index).toBe(0); + expect(pos2.offset).toBe(4); + }); + it('move forwards crossing to other part', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer()); + const pos = model.positionForOffset(4); + let n = 3; + const pos2 = pos.forwardsWhile(model, () => { n -= 1; return n >= 0; }); + expect(pos2.index).toBe(1); + expect(pos2.offset).toBe(2); + }); + it('move backwards within one part', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello")], pc, createRenderer()); + const pos = model.positionForOffset(4); + let n = 3; + const pos2 = pos.backwardsWhile(model, () => { n -= 1; return n >= 0; }); + expect(pos2.index).toBe(0); + expect(pos2.offset).toBe(1); + }); + it('move backwards crossing to other part', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer()); + const pos = model.positionForOffset(7); + let n = 3; + const pos2 = pos.backwardsWhile(model, () => { n -= 1; return n >= 0; }); + expect(pos2.index).toBe(0); + expect(pos2.offset).toBe(4); + }); +}); diff --git a/test/editor/range-test.js b/test/editor/range-test.js new file mode 100644 index 0000000000..5a95da952d --- /dev/null +++ b/test/editor/range-test.js @@ -0,0 +1,88 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +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 expect from 'expect'; +import EditorModel from "../../src/editor/model"; +import {createPartCreator} from "./mock"; + +function createRenderer() { + const render = (c) => { + render.caret = c; + render.count += 1; + }; + render.count = 0; + render.caret = null; + return render; +} + +const pillChannel = "#riot-dev:matrix.org"; + +describe('editor/range', function() { + it('range on empty model', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([], pc, renderer); + const range = model.startRange(model.positionForOffset(0, true)); // after "world" + let called = false; + range.expandBackwardsWhile(chr => { + called = true; + return true; + }); + expect(called).toBe(false); + expect(range.text).toBe(""); + }); + it('range replace within a part', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("hello world!!!!")], pc, renderer); + const range = model.startRange(model.positionForOffset(11)); // after "world" + range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); + expect(range.text).toBe("world"); + range.replace([pc.roomPill(pillChannel)]); + console.log({parts: JSON.stringify(model.serializeParts())}); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("hello "); + expect(model.parts[1].type).toBe("room-pill"); + expect(model.parts[1].text).toBe(pillChannel); + expect(model.parts[2].type).toBe("plain"); + expect(model.parts[2].text).toBe("!!!!"); + expect(model.parts.length).toBe(3); + expect(renderer.count).toBe(1); + }); + it('range replace across parts', function() { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("try to re"), + pc.plain("pla"), + pc.plain("ce "), + pc.plain("me"), + ], pc, renderer); + const range = model.startRange(model.positionForOffset(14)); // after "replace" + range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); + expect(range.text).toBe("replace"); + console.log("range.text", {text: range.text}); + range.replace([pc.roomPill(pillChannel)]); + expect(model.parts[0].type).toBe("plain"); + expect(model.parts[0].text).toBe("try to "); + expect(model.parts[1].type).toBe("room-pill"); + expect(model.parts[1].text).toBe(pillChannel); + expect(model.parts[2].type).toBe("plain"); + expect(model.parts[2].text).toBe(" me"); + expect(model.parts.length).toBe(3); + expect(renderer.count).toBe(1); + }); +}); diff --git a/test/i18n-test/languageHandler-test.js b/test/i18n-test/languageHandler-test.js index ce9f8e1684..0d96bc15ab 100644 --- a/test/i18n-test/languageHandler-test.js +++ b/test/i18n-test/languageHandler-test.js @@ -70,4 +70,15 @@ describe('languageHandler', function() { const text = '%(var1)s %(var2)s'; expect(languageHandler._t(text, { var2: 'val2', var1: 'val1' })).toBe('val1 val2'); }); + + it('multiple replacements of the same variable', function() { + const text = '%(var1)s %(var1)s'; + expect(languageHandler.substitute(text, { var1: 'val1' })).toBe('val1 val1'); + }); + + it('multiple replacements of the same tag', function() { + const text = 'Click here to join the discussion! or here'; + expect(languageHandler.substitute(text, {}, { 'a': (sub) => `x${sub}x` })) + .toBe('xClick herex to join the discussion! xor herex'); + }); }); diff --git a/yarn.lock b/yarn.lock index b9341b2a0e..c664d0b7dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6349,11 +6349,6 @@ react-addons-css-transition-group@15.3.2: resolved "https://registry.yarnpkg.com/react-addons-css-transition-group/-/react-addons-css-transition-group-15.3.2.tgz#d8fa52bec9bb61bdfde8b9e4652b80297cbff667" integrity sha1-2PpSvsm7Yb396LnkZSuAKXy/9mc= -react-addons-test-utils@^15.4.0: - version "15.6.2" - resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.6.2.tgz#c12b6efdc2247c10da7b8770d185080a7b047156" - integrity sha1-wStu/cIkfBDae4dw0YUICnsEcVY= - react-beautiful-dnd@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81"