diff --git a/CHANGELOG.md b/CHANGELOG.md index eea47dcb8f..742b8b4529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,57 @@ +Changes in [0.14.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.14.7) (2018-12-10) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.14.7-rc.2...v0.14.7) + + * No changes since rc.2 + +Changes in [0.14.7-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.14.7-rc.2) (2018-12-06) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.14.7-rc.1...v0.14.7-rc.2) + + * Ship the babelrc file to npm + [\#2332](https://github.com/matrix-org/matrix-react-sdk/pull/2332) + +Changes in [0.14.7-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.14.7-rc.1) (2018-12-06) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.14.6...v0.14.7-rc.1) + + * Suppress CORS errors in the 'failed to join room' dialog + [\#2306](https://github.com/matrix-org/matrix-react-sdk/pull/2306) + * Check if users exist before inviting them and communicate errors + [\#2317](https://github.com/matrix-org/matrix-react-sdk/pull/2317) + * Update from Weblate. + [\#2328](https://github.com/matrix-org/matrix-react-sdk/pull/2328) + * Allow group summary to load when /users fails + [\#2326](https://github.com/matrix-org/matrix-react-sdk/pull/2326) + * Show correct text if passphrase is skipped + [\#2324](https://github.com/matrix-org/matrix-react-sdk/pull/2324) + * Add password strength meter to backup creation UI + [\#2294](https://github.com/matrix-org/matrix-react-sdk/pull/2294) + * Check upload limits before trying to upload large files + [\#1876](https://github.com/matrix-org/matrix-react-sdk/pull/1876) + * Support .well-known discovery + [\#2227](https://github.com/matrix-org/matrix-react-sdk/pull/2227) + * Make create key backup dialog async + [\#2291](https://github.com/matrix-org/matrix-react-sdk/pull/2291) + * Forgot to enable continue button on download + [\#2288](https://github.com/matrix-org/matrix-react-sdk/pull/2288) + * Online incremental megolm backups (v2) + [\#2169](https://github.com/matrix-org/matrix-react-sdk/pull/2169) + * Add recovery key download button + [\#2284](https://github.com/matrix-org/matrix-react-sdk/pull/2284) + * Passphrase Support for e2e backups + [\#2283](https://github.com/matrix-org/matrix-react-sdk/pull/2283) + * Update async dialog interface to use promises + [\#2286](https://github.com/matrix-org/matrix-react-sdk/pull/2286) + * Support for m.login.sso + [\#2279](https://github.com/matrix-org/matrix-react-sdk/pull/2279) + * Added badge to non-autoplay GIFs + [\#2235](https://github.com/matrix-org/matrix-react-sdk/pull/2235) + * Improve terms auth flow + [\#2277](https://github.com/matrix-org/matrix-react-sdk/pull/2277) + * Handle crypto db version upgrade + [\#2282](https://github.com/matrix-org/matrix-react-sdk/pull/2282) + Changes in [0.14.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.14.6) (2018-11-22) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.14.5...v0.14.6) diff --git a/README.md b/README.md index ac45497dd4..ec95fbd132 100644 --- a/README.md +++ b/README.md @@ -127,61 +127,3 @@ Github Issues All issues should be filed under https://github.com/vector-im/riot-web/issues for now. - -OUTDATED: To Create Your Own Skin -================================= - -**This is ALL LIES currently, and needs to be updated** - -Skins are modules are exported from such a package in the `lib` directory. -`lib/skins` contains one directory per-skin, named after the skin, and the -`modules` directory contains modules as their javascript files. - -A basic skin is provided in the matrix-react-skin package. This also contains -a minimal application that instantiates the basic skin making a working matrix -client. - -You can use matrix-react-sdk directly, but to do this you would have to provide -'views' for each UI component. To get started quickly, use matrix-react-skin. - -To actually change the look of a skin, you can create a base skin (which -does not use views from any other skin) or you can make a derived skin. -Note that derived skins are currently experimental: for example, the CSS -from the skins it is based on will not be automatically included. - -To make a skin, create React classes for any custom components you wish to add -in a skin within `src/skins/`. These can be based off the files in -`views` in the `matrix-react-skin` package, modifying the require() statement -appropriately. - -If you make a derived skin, you only need copy the files you wish to customise. - -Once you've made all your view files, you need to make a `skinfo.json`. This -contains all the metadata for a skin. This is a JSON file with, currently, a -single key, 'baseSkin'. Set this to the empty string if your skin is a base skin, -or for a derived skin, set it to the path of your base skin's skinfo.json file, as -you would use in a require call. - -Now you have the basis of a skin, you need to generate a skindex.json file. The -`reskindex.js` tool in matrix-react-sdk does this for you. It is suggested that -you add an npm script to run this, as in matrix-react-skin. - -For more specific detail on any of these steps, look at matrix-react-skin. - -Alternative instructions: - - * Create a new NPM project. Be sure to directly depend on react, (otherwise - you can end up with two copies of react). - * Create an index.js file that sets up react. Add require statements for - React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the - SDK and call Render. This can be a skin provided by a separate package or - a skin in the same package. - * Add a way to build your project: we suggest copying the scripts block - from matrix-react-skin (which uses babel and webpack). You could use - different tools but remember that at least the skins and modules of - your project should end up in plain (ie. non ES6, non JSX) javascript in - the lib directory at the end of the build process, as well as any - packaging that you might do. - * Create an index.html file pulling in your compiled javascript and the - CSS bundle from the skin you use. For now, you'll also need to manually - import CSS from any skins that your skin inherts from. diff --git a/code_style.md b/code_style.md index 2cac303e54..96f3879ebc 100644 --- a/code_style.md +++ b/code_style.md @@ -165,7 +165,6 @@ ECMAScript React ----- -- Use React.createClass rather than ES6 classes for components, as the boilerplate is way too heavy on ES6 currently. ES7 might improve it. - Pull out functions in props to the class, generally as specific event handlers: ```jsx @@ -174,11 +173,38 @@ React // Better // Best, if onFooClick would do anything other than directly calling doStuff ``` - - Not doing so is acceptable in a single case; in function-refs: - + + Not doing so is acceptable in a single case: in function-refs: + ```jsx this.component = self}> ``` + +- Prefer classes that extend `React.Component` (or `React.PureComponent`) instead of `React.createClass` + - You can avoid the need to bind handler functions by using [property initializers](https://reactjs.org/docs/react-component.html#constructor): + + ```js + class Widget extends React.Component + onFooClick = () => { + ... + } + } + ``` + - To define `propTypes`, use a static property: + ```js + class Widget extends React.Component + static propTypes = { + ... + } + } + ``` + - If you need to specify initial component state, [assign it](https://reactjs.org/docs/react-component.html#constructor) to `this.state` in the constructor: + ```js + constructor(props) { + super(props); + // Don't call this.setState() here! + this.state = { counter: 0 }; + } + ``` - Think about whether your component really needs state: are you duplicating information in component state that could be derived from the model? diff --git a/package.json b/package.json index 67d1f3ba1e..7a63d55415 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.14.6", + "version": "0.14.7", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -10,6 +10,7 @@ "license": "Apache-2.0", "main": "lib/index.js", "files": [ + ".babelrc", ".eslintrc.js", "CHANGELOG.md", "CONTRIBUTING.rst", @@ -72,11 +73,12 @@ "gfm.css": "^1.1.1", "glob": "^5.0.14", "highlight.js": "^9.13.0", + "is-ip": "^2.0.0", "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.6", "lodash": "^4.13.1", "lolex": "2.3.2", - "matrix-js-sdk": "0.14.1", + "matrix-js-sdk": "0.14.2", "optimist": "^0.6.1", "pako": "^1.0.5", "prop-types": "^15.5.8", diff --git a/res/css/_components.scss b/res/css/_components.scss index 083071ef6c..579856f880 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -101,6 +101,7 @@ @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; +@import "./views/rooms/_RoomRecoveryReminder.scss"; @import "./views/rooms/_RoomSettings.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTooltip.scss"; diff --git a/res/css/views/rooms/_RoomRecoveryReminder.scss b/res/css/views/rooms/_RoomRecoveryReminder.scss new file mode 100644 index 0000000000..4bb42ff114 --- /dev/null +++ b/res/css/views/rooms/_RoomRecoveryReminder.scss @@ -0,0 +1,43 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomRecoveryReminder { + display: flex; + flex-direction: column; + text-align: center; + background-color: $room-warning-bg-color; + padding: 20px; + border: 1px solid $primary-hairline-color; + border-bottom: unset; +} + +.mx_RoomRecoveryReminder_header { + font-weight: bold; + margin-bottom: 1em; +} + +.mx_RoomRecoveryReminder_body { + margin-bottom: 1em; +} + +.mx_RoomRecoveryReminder_button { + @mixin mx_DialogButton; + margin: 0 10px; +} + +.mx_RoomRecoveryReminder_button.mx_RoomRecoveryReminder_secondary { + @mixin mx_DialogButton_secondary; +} diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index b773d7c720..5dbc00af4e 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -100,6 +100,8 @@ $voip-accept-color: #80f480; $rte-bg-color: #353535; $rte-code-bg-color: #000; +$room-warning-bg-color: #2d2d2d; + // ******************** $roomtile-name-color: rgba(186, 186, 186, 0.8); @@ -169,6 +171,14 @@ $progressbar-color: #000; outline: none; } +@define-mixin mx_DialogButton_secondary { + // flip colours for the secondary ones + font-weight: 600; + border: 1px solid $accent-color ! important; + color: $accent-color; + background-color: $accent-fg-color; +} + // Nasty hacks to apply a filter to arbitrary monochrome artwork to make it // better match the theme. Typically applied to dark grey 'off' buttons or // light grey 'on' buttons. diff --git a/res/themes/light/css/_base.scss b/res/themes/light/css/_base.scss index 49347492ff..c275b94fb5 100644 --- a/res/themes/light/css/_base.scss +++ b/res/themes/light/css/_base.scss @@ -155,6 +155,8 @@ $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); // unused? $progressbar-color: #000; +$room-warning-bg-color: #fff8e3; + // ***** Mixins! ***** @define-mixin mx_DialogButton { @@ -187,3 +189,11 @@ $progressbar-color: #000; font-size: 15px; padding: 0px 1.5em 0px 1.5em; } + +@define-mixin mx_DialogButton_secondary { + // flip colours for the secondary ones + font-weight: 600; + border: 1px solid $accent-color ! important; + color: $accent-color; + background-color: $accent-fg-color; +} diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js index a1a2e6f7c5..3d3d5af116 100755 --- a/scripts/gen-i18n.js +++ b/scripts/gen-i18n.js @@ -222,10 +222,21 @@ const translatables = new Set(); const walkOpts = { listeners: { + names: function(root, nodeNamesArray) { + // Sort the names case insensitively and alphabetically to + // maintain some sense of order between the different strings. + nodeNamesArray.sort((a, b) => { + a = a.toLowerCase(); + b = b.toLowerCase(); + if (a > b) return 1; + if (a < b) return -1; + return 0; + }); + }, file: function(root, fileStats, next) { const fullPath = path.join(root, fileStats.name); - let ltrs; + let trs; if (fileStats.name.endsWith('.js')) { trs = getTranslationsJs(fullPath); } else if (fileStats.name.endsWith('.html')) { @@ -235,7 +246,8 @@ const walkOpts = { } console.log(`${fullPath} (${trs.size} strings)`); for (const tr of trs.values()) { - translatables.add(tr); + // Convert DOS line endings to unix + translatables.add(tr.replace(/\r\n/g, "\n")); } }, } diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 8547add256..6b115b890f 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -251,7 +251,7 @@ export default React.createClass({ />

{_t( - "If you don't want encrypted message history to be availble on other devices, "+ + "If you don't want encrypted message history to be available on other devices, "+ ".", {}, { diff --git a/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js b/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js new file mode 100644 index 0000000000..a9df3cca6e --- /dev/null +++ b/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js @@ -0,0 +1,70 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import PropTypes from "prop-types"; +import sdk from "../../../../index"; +import { _t } from "../../../../languageHandler"; + +export default class IgnoreRecoveryReminderDialog extends React.PureComponent { + static propTypes = { + onDontAskAgain: PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, + onSetup: PropTypes.func.isRequired, + } + + onDontAskAgainClick = () => { + this.props.onFinished(); + this.props.onDontAskAgain(); + } + + onSetupClick = () => { + this.props.onFinished(); + this.props.onSetup(); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); + + return ( + +

+

{_t( + "Without setting up Secure Message Recovery, " + + "you'll lose your secure message history when you " + + "log out.", + )}

+

{_t( + "If you don't want to set this up now, you can later " + + "in Settings.", + )}

+
+ +
+
+ + ); + } +} diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index d798070659..cdeb8926c0 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -327,17 +327,10 @@ const RoomSubList = React.createClass({ let incomingCall; if (this.props.incomingCall) { - const self = this; - // Check if the incoming call is for this section - const incomingCallRoom = this.props.list.filter(function(room) { - return self.props.incomingCall.roomId === room.roomId; - }); - - if (incomingCallRoom.length === 1) { - const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox"); - incomingCall = - ; - } + // We can assume that if we have an incoming call then it is for this list + const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox"); + incomingCall = + ; } const tabindex = this.props.searchFilter === "" ? "0" : "-1"; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 934031e98d..0e0d56647d 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -607,6 +607,20 @@ module.exports = React.createClass({ } }, + async onRoomRecoveryReminderFinished(backupCreated) { + // If the user cancelled the key backup dialog, it suggests they don't + // want to be reminded anymore. + if (!backupCreated) { + await SettingsStore.setValue( + "showRoomRecoveryReminder", + null, + SettingLevel.ACCOUNT, + false, + ); + } + this.forceUpdate(); + }, + canResetTimeline: function() { if (!this.refs.messagePanel) { return true; @@ -1521,6 +1535,7 @@ module.exports = React.createClass({ const Loader = sdk.getComponent("elements.Spinner"); const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar"); + const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder"); if (!this.state.room) { if (this.state.roomLoading || this.state.peekLoading) { @@ -1655,6 +1670,13 @@ module.exports = React.createClass({ this.state.room.userMayUpgradeRoom(MatrixClientPeg.get().credentials.userId) ); + const showRoomRecoveryReminder = ( + SettingsStore.isFeatureEnabled("feature_keybackup") && + SettingsStore.getValue("showRoomRecoveryReminder") && + MatrixClientPeg.get().isRoomEncrypted(this.state.room.roomId) && + !MatrixClientPeg.get().getKeyBackupEnabled() + ); + let aux = null; let hideCancel = false; if (this.state.editingRoomSettings) { @@ -1669,6 +1691,9 @@ module.exports = React.createClass({ } else if (showRoomUpgradeBar) { aux = ; hideCancel = true; + } else if (showRoomRecoveryReminder) { + aux = ; + hideCancel = true; } else if (this.state.showingPinned) { hideCancel = true; // has own cancel aux = ; diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 4c15b4ec27..6f932d71e1 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -64,6 +64,7 @@ const SIMPLE_SETTINGS = [ { id: "urlPreviewsEnabled" }, { id: "autoplayGifsAndVideos" }, { id: "alwaysShowEncryptionIcons" }, + { id: "showRoomRecoveryReminder" }, { id: "hideReadReceipts" }, { id: "dontSendTypingNotifications" }, { id: "alwaysShowTimestamps" }, diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index 761a1e4209..6e87a816bb 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -35,19 +35,10 @@ export default class DeactivateAccountDialog extends React.Component { this._onPasswordFieldChange = this._onPasswordFieldChange.bind(this); this._onEraseFieldChange = this._onEraseFieldChange.bind(this); - const deactivationPreferences = - MatrixClientPeg.get().getAccountData('im.riot.account_deactivation_preferences'); - - const shouldErase = ( - deactivationPreferences && - deactivationPreferences.getContent() && - deactivationPreferences.getContent().shouldErase - ) || false; - this.state = { confirmButtonEnabled: false, busy: false, - shouldErase, + shouldErase: false, errStr: null, }; } @@ -67,36 +58,6 @@ export default class DeactivateAccountDialog extends React.Component { async _onOk() { this.setState({busy: true}); - // Before we deactivate the account insert an event into - // the user's account data indicating that they wish to be - // erased from the homeserver. - // - // We do this because the API for erasing after deactivation - // might not be supported by the connected homeserver. Leaving - // an indication in account data is only best-effort, and - // in the worse case, the HS maintainer would have to run a - // script to erase deactivated accounts that have shouldErase - // set to true in im.riot.account_deactivation_preferences. - // - // Note: The preferences are scoped to Riot, hence the - // "im.riot..." event type. - // - // Note: This may have already been set on previous attempts - // where, for example, the user entered the wrong password. - // This is fine because the UI always indicates the preference - // prior to us calling `deactivateAccount`. - try { - await MatrixClientPeg.get().setAccountData('im.riot.account_deactivation_preferences', { - shouldErase: this.state.shouldErase, - }); - } catch (err) { - this.setState({ - busy: false, - errStr: _t('Failed to indicate account erasure'), - }); - return; - } - try { // This assumes that the HS requires password UI auth // for this endpoint. In reality it could be any UI auth. diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 3e632ba8ce..3ad35c036d 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -71,6 +71,7 @@ module.exports = React.createClass({ isLoadingLeftRooms: false, totalRoomCount: null, lists: {}, + incomingCallTag: null, incomingCall: null, selectedTags: [], }; @@ -155,11 +156,13 @@ module.exports = React.createClass({ if (call && call.call_state === 'ringing') { this.setState({ incomingCall: call, + incomingCallTag: this.getTagNameForRoomId(payload.room_id), }); this._repositionIncomingCallBox(undefined, true); } else { this.setState({ incomingCall: null, + incomingCallTag: null, }); } break; @@ -328,6 +331,26 @@ module.exports = React.createClass({ // this._lastRefreshRoomListTs = Date.now(); }, + getTagNameForRoomId: function(roomId) { + const lists = RoomListStore.getRoomLists(); + for (const tagName of Object.keys(lists)) { + for (const room of lists[tagName]) { + // Should be impossible, but guard anyways. + if (!room) { + continue; + } + const myUserId = MatrixClientPeg.get().getUserId(); + if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, myUserId, this.props.ConferenceHandler)) { + continue; + } + + if (room.roomId === roomId) return tagName; + } + } + + return null; + }, + getRoomLists: function() { const lists = RoomListStore.getRoomLists(); @@ -621,6 +644,12 @@ module.exports = React.createClass({ // so checking on every render is the sanest thing at this time. const showEmpty = SettingsStore.getValue('RoomSubList.showEmpty'); + const incomingCallIfTaggedAs = (tagName) => { + if (!this.state.incomingCall) return null; + if (this.state.incomingCallTag !== tagName) return null; + return this.state.incomingCall; + }; + const self = this; return ( @@ -750,7 +779,7 @@ module.exports = React.createClass({ tagName="m.lowpriority" editable={false} order="recent" - incomingCall={self.state.incomingCall} + incomingCall={incomingCallIfTaggedAs('m.server_notice')} collapsed={self.props.collapsed} searchFilter={self.props.searchFilter} onHeaderClick={self.onSubListHeaderClick} diff --git a/src/components/views/rooms/RoomRecoveryReminder.js b/src/components/views/rooms/RoomRecoveryReminder.js new file mode 100644 index 0000000000..265bfd3ee3 --- /dev/null +++ b/src/components/views/rooms/RoomRecoveryReminder.js @@ -0,0 +1,85 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import PropTypes from "prop-types"; +import sdk from "../../../index"; +import { _t } from "../../../languageHandler"; +import Modal from "../../../Modal"; + +export default class RoomRecoveryReminder extends React.PureComponent { + static propTypes = { + onFinished: PropTypes.func.isRequired, + } + + showKeyBackupDialog = () => { + Modal.createTrackedDialogAsync("Key Backup", "Key Backup", + import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"), + { + onFinished: this.props.onFinished, + }, + ); + } + + onDontAskAgainClick = () => { + // When you choose "Don't ask again" from the room reminder, we show a + // dialog to confirm the choice. + Modal.createTrackedDialogAsync("Ignore Recovery Reminder", "Ignore Recovery Reminder", + import("../../../async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog"), + { + onDontAskAgain: () => { + // Report false to the caller, who should prevent the + // reminder from appearing in the future. + this.props.onFinished(false); + }, + onSetup: () => { + this.showKeyBackupDialog(); + }, + }, + ); + } + + onSetupClick = () => { + this.showKeyBackupDialog(); + } + + render() { + const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton"); + + return ( +
+
{_t( + "Secure Message Recovery", + )}
+
{_t( + "If you log out or use another device, you'll lose your " + + "secure message history. To prevent this, set up Secure " + + "Message Recovery.", + )}
+
+ + { _t("Don't ask again") } + + + { _t("Set up") } + +
+
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 48189d8b80..a4ce5143d7 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -43,6 +43,10 @@ "The file '%(fileName)s' failed to upload": "The file '%(fileName)s' failed to upload", "The file '%(fileName)s' exceeds this home server's size limit for uploads": "The file '%(fileName)s' exceeds this home server's size limit for uploads", "Upload Failed": "Upload Failed", + "Failure to create room": "Failure to create room", + "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", + "Send anyway": "Send anyway", + "Send": "Send", "Sun": "Sun", "Mon": "Mon", "Tue": "Tue", @@ -82,6 +86,7 @@ "Failed to invite users to community": "Failed to invite users to community", "Failed to invite users to %(groupId)s": "Failed to invite users to %(groupId)s", "Failed to add the following rooms to %(groupId)s:": "Failed to add the following rooms to %(groupId)s:", + "Unnamed Room": "Unnamed Room", "Error": "Error", "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", "Dismiss": "Dismiss", @@ -210,11 +215,6 @@ "%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing", "%(names)s and %(count)s others are typing|one": "%(names)s and one other is typing", "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", - "Failure to create room": "Failure to create room", - "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", - "Send anyway": "Send anyway", - "Send": "Send", - "Unnamed Room": "Unnamed Room", "This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.", "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", "Please contact your service administrator to continue using the service.": "Please contact your service administrator to continue using the service.", @@ -222,7 +222,9 @@ "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", "Not a valid Riot keyfile": "Not a valid Riot keyfile", "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", - "There was an error joining the room": "There was an error joining the room", + "You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.", + "User %(user_id)s does not exist": "User %(user_id)s does not exist", + "Unknown server error": "Unknown server error", "Use a few words, avoid common phrases": "Use a few words, avoid common phrases", "No need for symbols, digits, or uppercase letters": "No need for symbols, digits, or uppercase letters", "Use a longer keyboard pattern with more turns": "Use a longer keyboard pattern with more turns", @@ -248,9 +250,7 @@ "A word by itself is easy to guess": "A word by itself is easy to guess", "Names and surnames by themselves are easy to guess": "Names and surnames by themselves are easy to guess", "Common names and surnames are easy to guess": "Common names and surnames are easy to guess", - "You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.", - "User %(user_id)s does not exist": "User %(user_id)s does not exist", - "Unknown server error": "Unknown server error", + "There was an error joining the room": "There was an error joining the room", "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", "Failed to join room": "Failed to join room", @@ -268,6 +268,7 @@ "Always show message timestamps": "Always show message timestamps", "Autoplay GIFs and videos": "Autoplay GIFs and videos", "Always show encryption icons": "Always show encryption icons", + "Show a reminder to enable Secure Message Recovery in encrypted rooms": "Show a reminder to enable Secure Message Recovery in encrypted rooms", "Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting", "Hide avatars in user and room mentions": "Hide avatars in user and room mentions", "Disable big emoji in chat": "Disable big emoji in chat", @@ -491,11 +492,11 @@ "At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.", "Markdown is disabled": "Markdown is disabled", "Markdown is enabled": "Markdown is enabled", - "Unpin Message": "Unpin Message", - "Jump to message": "Jump to message", "No pinned messages.": "No pinned messages.", "Loading...": "Loading...", "Pinned Messages": "Pinned Messages", + "Unpin Message": "Unpin Message", + "Jump to message": "Jump to message", "%(duration)ss": "%(duration)ss", "%(duration)sm": "%(duration)sm", "%(duration)sh": "%(duration)sh", @@ -562,6 +563,10 @@ "You are trying to access a room.": "You are trying to access a room.", "Click here to join the discussion!": "Click here to join the discussion!", "This is a preview of this room. Room interactions have been disabled": "This is a preview of this room. Room interactions have been disabled", + "Secure Message Recovery": "Secure Message Recovery", + "If you log out or use another device, you'll lose your secure message history. To prevent this, set up Secure Message Recovery.": "If you log out or use another device, you'll lose your secure message history. To prevent this, set up Secure Message Recovery.", + "Don't ask again": "Don't ask again", + "Set up": "Set up", "To change the room's avatar, you must be a": "To change the room's avatar, you must be a", "To change the room's name, you must be a": "To change the room's name, you must be a", "To change the room's main address, you must be a": "To change the room's main address, you must be a", @@ -734,6 +739,7 @@ "Remove this user from community?": "Remove this user from community?", "Failed to withdraw invitation": "Failed to withdraw invitation", "Failed to remove user from community": "Failed to remove user from community", + "Failed to load group members": "Failed to load group members", "Filter community members": "Filter community members", "Flair will appear if enabled in room settings": "Flair will appear if enabled in room settings", "Flair will not appear": "Flair will not appear", @@ -1104,7 +1110,6 @@ "Community %(groupId)s not found": "Community %(groupId)s not found", "This Home server does not support communities": "This Home server does not support communities", "Failed to load %(groupId)s": "Failed to load %(groupId)s", - "Failed to load group members": "Failed to load group members", "Couldn't load home page": "Couldn't load home page", "You are currently using Riot anonymously as a guest.": "You are currently using Riot anonymously as a guest.", "If you would like to create a Matrix account you can register now.": "If you would like to create a Matrix account you can register now.", @@ -1352,7 +1357,7 @@ "Secure your encrypted message history with a Recovery Passphrase.": "Secure your encrypted message history with a Recovery Passphrase.", "You'll need it if you log out or lose access to this device.": "You'll need it if you log out or lose access to this device.", "Enter a passphrase...": "Enter a passphrase...", - "If you don't want encrypted message history to be availble on other devices, .": "If you don't want encrypted message history to be availble on other devices, .", + "If you don't want encrypted message history to be available on other devices, .": "If you don't want encrypted message history to be available on other devices, .", "Or, if you don't want to create a Recovery Passphrase, skip this step and .": "Or, if you don't want to create a Recovery Passphrase, skip this step and .", "That matches!": "That matches!", "That doesn't match.": "That doesn't match.", @@ -1384,6 +1389,8 @@ "Create Key Backup": "Create Key Backup", "Unable to create key backup": "Unable to create key backup", "Retry": "Retry", + "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.", + "If you don't want to set this up now, you can later in Settings.": "If you don't want to set this up now, you can later in Settings.", "Failed to set direct chat tag": "Failed to set direct chat tag", "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room" diff --git a/src/matrix-to.js b/src/matrix-to.js index b5827f671a..b750dff6d6 100644 --- a/src/matrix-to.js +++ b/src/matrix-to.js @@ -15,6 +15,8 @@ limitations under the License. */ import MatrixClientPeg from "./MatrixClientPeg"; +import isIp from "is-ip"; +import utils from 'matrix-js-sdk/lib/utils'; export const host = "matrix.to"; export const baseUrl = `https://${host}`; @@ -90,7 +92,9 @@ export function pickServerCandidates(roomId) { // Rationale for popular servers: It's hard to get rid of people when // they keep flocking in from a particular server. Sure, the server could // be ACL'd in the future or for some reason be evicted from the room - // however an event like that is unlikely the larger the room gets. + // however an event like that is unlikely the larger the room gets. If + // the server is ACL'd at the time of generating the link however, we + // shouldn't pick them. We also don't pick IP addresses. // Note: we don't pick the server the room was created on because the // homeserver should already be using that server as a last ditch attempt @@ -104,12 +108,29 @@ export function pickServerCandidates(roomId) { // The receiving user can then manually append the known-good server to // the list and magically have the link work. + const bannedHostsRegexps = []; + let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone + if (room.currentState) { + const aclEvent = room.currentState.getStateEvents("m.room.server_acl", ""); + if (aclEvent && aclEvent.getContent()) { + const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$"); + + const denied = aclEvent.getContent().deny || []; + denied.forEach(h => bannedHostsRegexps.push(getRegex(h))); + + const allowed = aclEvent.getContent().allow || []; + allowedHostsRegexps = []; // we don't want to use the default rule here + allowed.forEach(h => allowedHostsRegexps.push(getRegex(h))); + } + } + const populationMap: {[server:string]:number} = {}; const highestPlUser = {userId: null, powerLevel: 0, serverName: null}; for (const member of room.getJoinedMembers()) { const serverName = member.userId.split(":").splice(1).join(":"); - if (member.powerLevel > highestPlUser.powerLevel) { + if (member.powerLevel > highestPlUser.powerLevel && !isHostnameIpAddress(serverName) + && !isHostInRegex(serverName, bannedHostsRegexps) && isHostInRegex(serverName, allowedHostsRegexps)) { highestPlUser.userId = member.userId; highestPlUser.powerLevel = member.powerLevel; highestPlUser.serverName = serverName; @@ -125,8 +146,9 @@ export function pickServerCandidates(roomId) { const beforePopulation = candidates.length; const serversByPopulation = Object.keys(populationMap) .sort((a, b) => populationMap[b] - populationMap[a]) - .filter(a => !candidates.includes(a)); - for (let i = beforePopulation; i <= MAX_SERVER_CANDIDATES; i++) { + .filter(a => !candidates.includes(a) && !isHostnameIpAddress(a) + && !isHostInRegex(a, bannedHostsRegexps) && isHostInRegex(a, allowedHostsRegexps)); + for (let i = beforePopulation; i < MAX_SERVER_CANDIDATES; i++) { const idx = i - beforePopulation; if (idx >= serversByPopulation.length) break; candidates.push(serversByPopulation[idx]); @@ -134,3 +156,34 @@ export function pickServerCandidates(roomId) { return candidates; } + +function getHostnameFromMatrixDomain(domain) { + if (!domain) return null; + + // The hostname might have a port, so we convert it to a URL and + // split out the real hostname. + const parser = document.createElement('a'); + parser.href = "https://" + domain; + return parser.hostname; +} + +function isHostInRegex(hostname, regexps) { + hostname = getHostnameFromMatrixDomain(hostname); + if (!hostname) return true; // assumed + if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0]); + + return regexps.filter(h => h.test(hostname)).length > 0; +} + +function isHostnameIpAddress(hostname) { + hostname = getHostnameFromMatrixDomain(hostname); + if (!hostname) return false; + + // is-ip doesn't want IPv6 addresses surrounded by brackets, so + // take them off. + if (hostname.startsWith("[") && hostname.endsWith("]")) { + hostname = hostname.substring(1, hostname.length - 1); + } + + return isIp(hostname); +} diff --git a/src/settings/Settings.js b/src/settings/Settings.js index eb702a729c..c9a4ecdebe 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -151,6 +151,11 @@ export const SETTINGS = { displayName: _td('Always show encryption icons'), default: true, }, + "showRoomRecoveryReminder": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td('Show a reminder to enable Secure Message Recovery in encrypted rooms'), + default: true, + }, "enableSyntaxHighlightLanguageDetection": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Enable automatic language detection for syntax highlighting'), diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js index 3aad05a976..adc89a126a 100644 --- a/src/shouldHideEvent.js +++ b/src/shouldHideEvent.js @@ -38,18 +38,20 @@ function memberEventDiff(ev) { } export default function shouldHideEvent(ev) { - // Wrap getValue() for readability + // Wrap getValue() for readability. Calling the SettingsStore can be + // fairly resource heavy, so the checks below should avoid hitting it + // where possible. const isEnabled = (name) => SettingsStore.getValue(name, ev.getRoomId()); // Hide redacted events - if (isEnabled('hideRedactions') && ev.isRedacted()) return true; + if (ev.isRedacted() && isEnabled('hideRedactions')) return true; const eventDiff = memberEventDiff(ev); if (eventDiff.isMemberEvent) { - if (isEnabled('hideJoinLeaves') && (eventDiff.isJoin || eventDiff.isPart)) return true; - if (isEnabled('hideAvatarChanges') && eventDiff.isAvatarChange) return true; - if (isEnabled('hideDisplaynameChanges') && eventDiff.isDisplaynameChange) return true; + if ((eventDiff.isJoin || eventDiff.isPart) && isEnabled('hideJoinLeaves')) return true; + if (eventDiff.isAvatarChange && isEnabled('hideAvatarChanges')) return true; + if (eventDiff.isDisplaynameChange && isEnabled('hideDisplaynameChanges')) return true; } return false; diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 0f8e5d7b4d..0d99377180 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -300,6 +300,10 @@ class RoomListStore extends Store { const ts = this._tsOfNewestEvent(room); this._updateCachedRoomState(roomId, "timestamp", ts); return ts; + } else if (type === "unread-muted") { + const unread = Unread.doesRoomHaveUnreadMessages(room); + this._updateCachedRoomState(roomId, "unread-muted", unread); + return unread; } else if (type === "unread") { const unread = room.getUnreadNotificationCount() > 0; this._updateCachedRoomState(roomId, "unread", unread); @@ -358,8 +362,21 @@ class RoomListStore extends Store { } if (pinUnread) { - const unreadA = this._getRoomState(roomA, "unread"); - const unreadB = this._getRoomState(roomB, "unread"); + let unreadA = this._getRoomState(roomA, "unread"); + let unreadB = this._getRoomState(roomB, "unread"); + if (unreadA && !unreadB) return -1; + if (!unreadA && unreadB) return 1; + + // If they both have unread messages, sort by timestamp + // If nether have unread message (the fourth check not shown + // here), then just sort by timestamp anyways. + if (unreadA && unreadB) return timestampDiff; + + // Unread can also mean "unread without badge", which is + // different from what the above checks for. We're also + // going to sort those here. + unreadA = this._getRoomState(roomA, "unread-muted"); + unreadB = this._getRoomState(roomB, "unread-muted"); if (unreadA && !unreadB) return -1; if (!unreadA && unreadB) return 1; diff --git a/test/matrix-to-test.js b/test/matrix-to-test.js index 70533575c4..6392e326e9 100644 --- a/test/matrix-to-test.js +++ b/test/matrix-to-test.js @@ -150,7 +150,39 @@ describe('matrix-to', function() { expect(pickedServers[2]).toBe("third"); }); - it('should work with IPv4 hostnames', function() { + it('should pick a maximum of 3 candidate servers', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:alpha", + powerLevel: 100, + }, + { + userId: "@alice:bravo", + powerLevel: 0, + }, + { + userId: "@alice:charlie", + powerLevel: 0, + }, + { + userId: "@alice:delta", + powerLevel: 0, + }, + { + userId: "@alice:echo", + powerLevel: 0, + }, + ], + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(3); + }); + + it('should not consider IPv4 hosts', function() { peg.get().getRoom = () => { return { getJoinedMembers: () => [ @@ -163,11 +195,10 @@ describe('matrix-to', function() { }; const pickedServers = pickServerCandidates("!somewhere:example.org"); expect(pickedServers).toExist(); - expect(pickedServers.length).toBe(1); - expect(pickedServers[0]).toBe("127.0.0.1"); + expect(pickedServers.length).toBe(0); }); - it('should work with IPv6 hostnames', function() { + it('should not consider IPv6 hosts', function() { peg.get().getRoom = () => { return { getJoinedMembers: () => [ @@ -180,11 +211,10 @@ describe('matrix-to', function() { }; const pickedServers = pickServerCandidates("!somewhere:example.org"); expect(pickedServers).toExist(); - expect(pickedServers.length).toBe(1); - expect(pickedServers[0]).toBe("[::1]"); + expect(pickedServers.length).toBe(0); }); - it('should work with IPv4 hostnames with ports', function() { + it('should not consider IPv4 hostnames with ports', function() { peg.get().getRoom = () => { return { getJoinedMembers: () => [ @@ -197,11 +227,10 @@ describe('matrix-to', function() { }; const pickedServers = pickServerCandidates("!somewhere:example.org"); expect(pickedServers).toExist(); - expect(pickedServers.length).toBe(1); - expect(pickedServers[0]).toBe("127.0.0.1:8448"); + expect(pickedServers.length).toBe(0); }); - it('should work with IPv6 hostnames with ports', function() { + it('should not consider IPv6 hostnames with ports', function() { peg.get().getRoom = () => { return { getJoinedMembers: () => [ @@ -214,8 +243,7 @@ describe('matrix-to', function() { }; const pickedServers = pickServerCandidates("!somewhere:example.org"); expect(pickedServers).toExist(); - expect(pickedServers.length).toBe(1); - expect(pickedServers[0]).toBe("[::1]:8448"); + expect(pickedServers.length).toBe(0); }); it('should work with hostnames with ports', function() { @@ -235,6 +263,140 @@ describe('matrix-to', function() { expect(pickedServers[0]).toBe("example.org:8448"); }); + it('should not consider servers explicitly denied by ACLs', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:evilcorp.com", + powerLevel: 100, + }, + { + userId: "@bob:chat.evilcorp.com", + powerLevel: 0, + }, + ], + currentState: { + getStateEvents: (type, key) => { + if (type !== "m.room.server_acl" || key !== "") return null; + return { + getContent: () => { + return { + deny: ["evilcorp.com", "*.evilcorp.com"], + allow: ["*"], + }; + }, + }; + }, + }, + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(0); + }); + + it('should not consider servers not allowed by ACLs', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:evilcorp.com", + powerLevel: 100, + }, + { + userId: "@bob:chat.evilcorp.com", + powerLevel: 0, + }, + ], + currentState: { + getStateEvents: (type, key) => { + if (type !== "m.room.server_acl" || key !== "") return null; + return { + getContent: () => { + return { + deny: [], + allow: [], // implies "ban everyone" + }; + }, + }; + }, + }, + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(0); + }); + + it('should consider servers not explicitly banned by ACLs', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:evilcorp.com", + powerLevel: 100, + }, + { + userId: "@bob:chat.evilcorp.com", + powerLevel: 0, + }, + ], + currentState: { + getStateEvents: (type, key) => { + if (type !== "m.room.server_acl" || key !== "") return null; + return { + getContent: () => { + return { + deny: ["*.evilcorp.com"], // evilcorp.com is still good though + allow: ["*"], + }; + }, + }; + }, + }, + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(1); + expect(pickedServers[0]).toEqual("evilcorp.com"); + }); + + it('should consider servers not disallowed by ACLs', function() { + peg.get().getRoom = () => { + return { + getJoinedMembers: () => [ + { + userId: "@alice:evilcorp.com", + powerLevel: 100, + }, + { + userId: "@bob:chat.evilcorp.com", + powerLevel: 0, + }, + ], + currentState: { + getStateEvents: (type, key) => { + if (type !== "m.room.server_acl" || key !== "") return null; + return { + getContent: () => { + return { + deny: [], + allow: ["evilcorp.com"], // implies "ban everyone else" + }; + }, + }; + }, + }, + }; + }; + const pickedServers = pickServerCandidates("!somewhere:example.org"); + expect(pickedServers).toExist(); + expect(pickedServers.length).toBe(1); + expect(pickedServers[0]).toEqual("evilcorp.com"); + }); + it('should generate an event permalink for room IDs with no candidate servers', function() { peg.get().getRoom = () => null; const result = makeEventPermalink("!somewhere:example.org", "$something:example.com");