diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index f5f63b647a..4bc69a76bd 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -1,8 +1,10 @@ steps: - label: ":eslint: JS Lint" command: + # We fetch the develop js-sdk to get our latest eslint rules - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" + - "./scripts/ci/install-deps.sh --ignore-scripts" + - "echo '+++ Lint'" - "yarn lint:js" plugins: - docker#v3.0.1: @@ -10,8 +12,9 @@ steps: - label: ":eslint: TS Lint" command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" + - "echo '--- Install'" + - "yarn install --ignore-scripts" + - "echo '+++ Lint'" - "yarn lint:ts" plugins: - docker#v3.0.1: @@ -19,12 +22,21 @@ steps: - label: ":eslint: Types Lint" command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" + - "echo '--- Install'" + - "yarn install --ignore-scripts" + - "echo '+++ Lint'" - "yarn lint:types" plugins: - docker#v3.0.1: image: "node:12" + - label: ":stylelint: Style Lint" + command: + - "echo '--- Install'" + - "yarn install --ignore-scripts" + - "yarn lint:style" + plugins: + - docker#v3.0.1: + image: "node:12" - label: ":jest: Tests" agents: @@ -33,13 +45,11 @@ steps: queue: "medium" command: - "echo '--- Install js-sdk'" - # TODO: Remove hacky chmod for BuildKite - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - - "echo '--- Installing Dependencies'" - - "./scripts/ci/install-deps.sh" - - "echo '--- Running initial build steps'" - - "yarn build" + # We don't use the babel-ed output for anything so we can --ignore-scripts + # to save transpiling the files. We run the transpile step explicitly in + # the 'build' job. + - "./scripts/ci/install-deps.sh --ignore-scripts" + - "yarn run reskindex" - "echo '+++ Running Tests'" - "yarn test" plugins: @@ -48,10 +58,8 @@ steps: - label: "🛠 Build" command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "echo '+++ Building Project'" - - "yarn build" + - "echo '+++ Install & Build'" + - "yarn install" plugins: - docker#v3.0.1: image: "node:12" @@ -62,14 +70,8 @@ steps: # e2e tests otherwise take +-8min queue: "xlarge" command: - # TODO: Remove hacky chmod for BuildKite - - "echo '--- Setup'" - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "echo '--- Running initial build steps'" - - "yarn build" + - "./scripts/ci/install-deps.sh --ignore-scripts" - "echo '+++ Running Tests'" - "./scripts/ci/end-to-end-tests.sh" plugins: @@ -88,9 +90,6 @@ steps: # webpack loves to gorge itself on resources. queue: "medium" command: - # TODO: Remove hacky chmod for BuildKite - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - "echo '+++ Running Tests'" - "./scripts/ci/riot-unit-tests.sh" plugins: @@ -102,7 +101,7 @@ steps: - label: "🌐 i18n" command: - "echo '--- Fetching Dependencies'" - - "yarn install" + - "yarn install --ignore-scripts" - "echo '+++ Testing i18n output'" - "yarn diff-i18n" plugins: diff --git a/res/css/_components.scss b/res/css/_components.scss index 60f749de9c..07e92bdc7b 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -148,6 +148,7 @@ @import "./views/rooms/_AuxPanel.scss"; @import "./views/rooms/_BasicMessageComposer.scss"; @import "./views/rooms/_E2EIcon.scss"; +@import "./views/rooms/_InviteOnlyIcon.scss"; @import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EventTile.scss"; diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index b05629003e..d342de6d75 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -95,6 +95,10 @@ limitations under the License. } } +.mx_AuthBody_noHeader { + border-radius: 4px; +} + .mx_AuthBody_editServerDetails { padding-left: 1em; font-size: 12px; diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index e87fe06a94..d2d9d12c6d 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -23,15 +23,23 @@ limitations under the License. font-size: 12px; .mx_UserInfo_cancel { - height: 16px; - width: 16px; - padding: 10px 0 10px 10px; cursor: pointer; - mask-image: url('$(res)/img/minimise.svg'); - mask-repeat: no-repeat; - mask-position: 16px center; - background-color: $rightpanel-button-color; position: absolute; + top: 0; + border-radius: 4px; + background-color: $dark-panel-bg-color; + margin: 9px; + z-index: 1; // render on top of the right panel + + div { + height: 16px; + width: 16px; + padding: 4px; + mask-image: url('$(res)/img/minimise.svg'); + mask-repeat: no-repeat; + mask-position: 7px center; + background-color: $rightpanel-button-color; + } } h2 { diff --git a/res/css/views/rooms/_InviteOnlyIcon.scss b/res/css/views/rooms/_InviteOnlyIcon.scss new file mode 100644 index 0000000000..e70586bb73 --- /dev/null +++ b/res/css/views/rooms/_InviteOnlyIcon.scss @@ -0,0 +1,38 @@ +/* +Copyright 2020 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. +*/ + +.mx_InviteOnlyIcon { + width: 12px; + height: 12px; + position: relative; + display: block !important; + // Align the padlock with unencrypted room names + margin-left: 6px; + + &::before { + background-color: $roomtile-name-color; + mask-image: url('$(res)/img/feather-customised/lock-solid.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } +} diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 5efca51844..fae9d0dfe3 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -76,6 +76,8 @@ limitations under the License. left: 60px; margin-right: 0; // Counteract the E2EIcon class margin-left: 3px; // Counteract the E2EIcon class + width: 12px; + height: 12px; } .mx_MessageComposer_noperm_error { diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 0d92247735..6f0377b29c 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -21,8 +21,10 @@ limitations under the License. .mx_E2EIcon { margin: 0; position: absolute; - bottom: 0; - right: -5px; + bottom: -1px; + right: -2px; + height: 10px; + width: 10px; } } @@ -267,24 +269,3 @@ limitations under the License. .mx_RoomHeader_pinsIndicatorUnread { background-color: $pinned-unread-color; } - -.mx_RoomHeader_PrivateIcon.mx_RoomHeader_isPrivate { - width: 12px; - height: 12px; - position: relative; - display: block !important; - - &::before { - background-color: $roomtile-name-color; - mask-image: url('$(res)/img/feather-customised/lock-solid.svg'); - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - } -} diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index db2c09f6f1..a24fdf2629 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -98,6 +98,19 @@ limitations under the License. z-index: 2; } +// Note we match .mx_E2EIcon to make sure this matches more tightly than just +// .mx_E2EIcon on its own +.mx_RoomTile_e2eIcon.mx_E2EIcon { + height: 10px; + width: 10px; + display: block; + position: absolute; + bottom: -1px; + right: -2px; + z-index: 1; + margin: 0; +} + .mx_RoomTile_name { font-size: 14px; padding: 0 6px; @@ -202,30 +215,7 @@ limitations under the License. flex: 1; } -.mx_RoomTile.mx_RoomTile.mx_RoomTile_isPrivate .mx_RoomTile_name { +.mx_InviteOnlyIcon + .mx_RoomTile_nameContainer .mx_RoomTile_name { // Scoot the padding in a bit from 6px to make it look better padding-left: 3px; } - -.mx_RoomTile.mx_RoomTile_isPrivate .mx_RoomTile_PrivateIcon { - width: 12px; - height: 12px; - position: relative; - display: block !important; - // Align the padlock with unencrypted room names - margin-left: 6px; - - &::before { - background-color: $roomtile-name-color; - mask-image: url('$(res)/img/feather-customised/lock-solid.svg'); - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - } -} diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh index a2e2e59a45..14b5fc5393 100755 --- a/scripts/ci/install-deps.sh +++ b/scripts/ci/install-deps.sh @@ -6,9 +6,9 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk pushd matrix-js-sdk yarn link -yarn install +yarn install $@ yarn build popd yarn link matrix-js-sdk -yarn install +yarn install $@ diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh old mode 100644 new mode 100755 diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.js new file mode 100644 index 0000000000..b7b81688e1 --- /dev/null +++ b/src/AsyncWrapper.js @@ -0,0 +1,92 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 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 createReactClass from 'create-react-class'; +import * as sdk from './index'; +import PropTypes from 'prop-types'; +import { _t } from './languageHandler'; + +/** + * Wrap an asynchronous loader function with a react component which shows a + * spinner until the real component loads. + */ +export default createReactClass({ + propTypes: { + /** A promise which resolves with the real component + */ + prom: PropTypes.object.isRequired, + }, + + getInitialState: function() { + return { + component: null, + error: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Starting load of AsyncWrapper for modal'); + this.props.prom.then((result) => { + if (this._unmounted) { + return; + } + // Take the 'default' member if it's there, then we support + // passing in just an import()ed module, since ES6 async import + // always returns a module *namespace*. + const component = result.default ? result.default : result; + this.setState({component}); + }).catch((e) => { + console.warn('AsyncWrapper promise failed', e); + this.setState({error: e}); + }); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _onWrapperCancelClick: function() { + this.props.onFinished(false); + }, + + render: function() { + if (this.state.component) { + const Component = this.state.component; + return ; + } else if (this.state.error) { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return + {_t("Unable to load! Check your network connectivity and try again.")} + + ; + } else { + // show a spinner until the component is loaded. + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + }, +}); + diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 1603c73d25..7488488dd8 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -592,8 +592,11 @@ async function startMatrixClient(startSyncing=true) { Mjolnir.sharedInstance().start(); if (startSyncing) { - await MatrixClientPeg.start(); + // The client might want to populate some views with events from the + // index (e.g. the FilePanel), therefore initialize the event index + // before the client. await EventIndexPeg.init(); + await MatrixClientPeg.start(); } else { console.warn("Caller requested only auxiliary services be started"); await MatrixClientPeg.assign(); diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index dbc570c872..450bec8e77 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -217,7 +217,7 @@ class _MatrixClientPeg { timelineSupport: true, forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'), - verificationMethods: [verificationMethods.SAS], + verificationMethods: [verificationMethods.SAS, verificationMethods.QR_CODE_SHOW], unstableClientRelationAggregation: true, identityServer: new IdentityAuthClient(), }; diff --git a/src/Modal.js b/src/Modal.js index 29d3af2e74..b6215b2b5a 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -17,87 +17,14 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import Analytics from './Analytics'; -import * as sdk from './index'; import dis from './dispatcher'; -import { _t } from './languageHandler'; -import {defer} from "./utils/promise"; +import {defer} from './utils/promise'; +import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; -/** - * Wrap an asynchronous loader function with a react component which shows a - * spinner until the real component loads. - */ -const AsyncWrapper = createReactClass({ - propTypes: { - /** A promise which resolves with the real component - */ - prom: PropTypes.object.isRequired, - }, - - getInitialState: function() { - return { - component: null, - error: null, - }; - }, - - componentWillMount: function() { - this._unmounted = false; - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/riot-web/issues/3148 - console.log('Starting load of AsyncWrapper for modal'); - this.props.prom.then((result) => { - if (this._unmounted) { - return; - } - // Take the 'default' member if it's there, then we support - // passing in just an import()ed module, since ES6 async import - // always returns a module *namespace*. - const component = result.default ? result.default : result; - this.setState({component}); - }).catch((e) => { - console.warn('AsyncWrapper promise failed', e); - this.setState({error: e}); - }); - }, - - componentWillUnmount: function() { - this._unmounted = true; - }, - - _onWrapperCancelClick: function() { - this.props.onFinished(false); - }, - - render: function() { - if (this.state.component) { - const Component = this.state.component; - return ; - } else if (this.state.error) { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return - {_t("Unable to load! Check your network connectivity and try again.")} - - ; - } else { - // show a spinner until the component is loaded. - const Spinner = sdk.getComponent("elements.Spinner"); - return ; - } - }, -}); - class ModalManager { constructor() { this._counter = 0; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 61b3d2d4b9..4c02f925fc 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -19,9 +19,10 @@ import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import Matrix from 'matrix-js-sdk'; +import {Filter} from 'matrix-js-sdk'; import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; +import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; /* @@ -29,6 +30,9 @@ import { _t } from '../../languageHandler'; */ const FilePanel = createReactClass({ displayName: 'FilePanel', + // This is used to track if a decrypted event was a live event and should be + // added to the timeline. + decryptingEvents: new Set(), propTypes: { roomId: PropTypes.string.isRequired, @@ -40,42 +44,147 @@ const FilePanel = createReactClass({ }; }, - componentDidMount: function() { - this.updateTimelineSet(this.props.roomId); + onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + if (room.roomId !== this.props.roomId) return; + if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return; + + if (ev.isBeingDecrypted()) { + this.decryptingEvents.add(ev.getId()); + } else { + this.addEncryptedLiveEvent(ev); + } }, - updateTimelineSet: function(roomId) { + onEventDecrypted(ev, err) { + if (ev.getRoomId() !== this.props.roomId) return; + const eventId = ev.getId(); + + if (!this.decryptingEvents.delete(eventId)) return; + if (err) return; + + this.addEncryptedLiveEvent(ev); + }, + + addEncryptedLiveEvent(ev, toStartOfTimeline) { + if (!this.state.timelineSet) return; + + const timeline = this.state.timelineSet.getLiveTimeline(); + if (ev.getType() !== "m.room.message") return; + if (["m.file", "m.image", "m.video", "m.audio"].indexOf(ev.getContent().msgtype) == -1) { + return; + } + + if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) { + this.state.timelineSet.addEventToTimeline(ev, timeline, false); + } + }, + + async componentDidMount() { + const client = MatrixClientPeg.get(); + + await this.updateTimelineSet(this.props.roomId); + + if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return; + + // The timelineSets filter makes sure that encrypted events that contain + // URLs never get added to the timeline, even if they are live events. + // These methods are here to manually listen for such events and add + // them despite the filter's best efforts. + // + // We do this only for encrypted rooms and if an event index exists, + // this could be made more general in the future or the filter logic + // could be fixed. + if (EventIndexPeg.get() !== null) { + client.on('Room.timeline', this.onRoomTimeline.bind(this)); + client.on('Event.decrypted', this.onEventDecrypted.bind(this)); + } + }, + + componentWillUnmount() { + const client = MatrixClientPeg.get(); + if (client === null) return; + + if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return; + + if (EventIndexPeg.get() !== null) { + client.removeListener('Room.timeline', this.onRoomTimeline.bind(this)); + client.removeListener('Event.decrypted', this.onEventDecrypted.bind(this)); + } + }, + + async fetchFileEventsServer(room) { + const client = MatrixClientPeg.get(); + + const filter = new Filter(client.credentials.userId); + filter.setDefinition( + { + "room": { + "timeline": { + "contains_url": true, + "types": [ + "m.room.message", + ], + }, + }, + }, + ); + + const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter); + filter.filterId = filterId; + const timelineSet = room.getOrCreateFilteredTimelineSet(filter); + + return timelineSet; + }, + + onPaginationRequest(timelineWindow, direction, limit) { + const client = MatrixClientPeg.get(); + const eventIndex = EventIndexPeg.get(); + const roomId = this.props.roomId; + + const room = client.getRoom(roomId); + + // We override the pagination request for encrypted rooms so that we ask + // the event index to fulfill the pagination request. Asking the server + // to paginate won't ever work since the server can't correctly filter + // out events containing URLs + if (client.isRoomEncrypted(roomId) && eventIndex !== null) { + return eventIndex.paginateTimelineWindow(room, timelineWindow, direction, limit); + } else { + return timelineWindow.paginate(direction, limit); + } + }, + + async updateTimelineSet(roomId: string) { const client = MatrixClientPeg.get(); const room = client.getRoom(roomId); + const eventIndex = EventIndexPeg.get(); this.noRoom = !room; if (room) { - const filter = new Matrix.Filter(client.credentials.userId); - filter.setDefinition( - { - "room": { - "timeline": { - "contains_url": true, - "types": [ - "m.room.message", - ], - }, - }, - }, - ); + let timelineSet; - // FIXME: we shouldn't be doing this every time we change room - see comment above. - client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then( - (filterId)=>{ - filter.filterId = filterId; - const timelineSet = room.getOrCreateFilteredTimelineSet(filter); - this.setState({ timelineSet: timelineSet }); - }, - (error)=>{ - console.error("Failed to get or create file panel filter", error); - }, - ); + try { + timelineSet = await this.fetchFileEventsServer(room); + + // If this room is encrypted the file panel won't be populated + // correctly since the defined filter doesn't support encrypted + // events and the server can't check if encrypted events contain + // URLs. + // + // This is where our event index comes into place, we ask the + // event index to populate the timelineSet for us. This call + // will add 10 events to the live timeline of the set. More can + // be requested using pagination. + if (client.isRoomEncrypted(roomId) && eventIndex !== null) { + const timeline = timelineSet.getLiveTimeline(); + await eventIndex.populateFileTimeline(timelineSet, timeline, room, 10); + } + + this.setState({ timelineSet: timelineSet }); + } catch (error) { + console.error("Failed to get or create file panel filter", error); + } } else { console.error("Failed to add filtered timelineSet for FilePanel as no room!"); } @@ -111,6 +220,7 @@ const FilePanel = createReactClass({ manageReadMarkers={false} timelineSet={this.state.timelineSet} showUrlPreview = {false} + onPaginationRequest={this.onPaginationRequest} tileShape="file_grid" resizeNotifier={this.props.resizeNotifier} empty={_t('There are no visible files in this room')} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 3f438ea909..5c243f04bc 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -796,6 +796,7 @@ export default createReactClass({ return; } + // Duplication between here and _updateE2eStatus in RoomTile /* At this point, the user has encryption on and cross-signing on */ const e2eMembers = await room.getEncryptionTargetMembers(); const verified = []; @@ -812,10 +813,10 @@ export default createReactClass({ /* Check all verified user devices. */ for (const userId of verified) { const devices = await cli.getStoredDevicesForUser(userId); - const allDevicesVerified = devices.every(({deviceId}) => { - return cli.checkDeviceTrust(userId, deviceId).isVerified(); + const anyDeviceNotVerified = devices.some(({deviceId}) => { + return !cli.checkDeviceTrust(userId, deviceId).isVerified(); }); - if (!allDevicesVerified) { + if (anyDeviceNotVerified) { this.setState({ e2eStatus: "warning", }); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index bd13981d1f..65fb00c305 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -94,6 +94,10 @@ const TimelinePanel = createReactClass({ // callback which is called when the read-up-to mark is updated. onReadMarkerUpdated: PropTypes.func, + // callback which is called when we wish to paginate the timeline + // window. + onPaginationRequest: PropTypes.func, + // maximum number of events to show in a timeline timelineCap: PropTypes.number, @@ -338,6 +342,14 @@ const TimelinePanel = createReactClass({ } }, + onPaginationRequest(timelineWindow, direction, size) { + if (this.props.onPaginationRequest) { + return this.props.onPaginationRequest(timelineWindow, direction, size); + } else { + return timelineWindow.paginate(direction, size); + } + }, + // set off a pagination request. onMessageListFillRequest: function(backwards) { if (!this._shouldPaginate()) return Promise.resolve(false); @@ -360,7 +372,7 @@ const TimelinePanel = createReactClass({ debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); this.setState({[paginatingKey]: true}); - return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => { + return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => { if (this.unmounted) { return; } debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.js index b64f368908..89711fcb1d 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.js @@ -44,9 +44,12 @@ export default class CompleteSecurity extends React.Component { await accessSecretStorage(async () => { await cli.checkOwnCrossSigningTrust(); }); - this.setState({ - phase: PHASE_DONE, - }); + + if (cli.getCrossSigningId()) { + this.setState({ + phase: PHASE_DONE, + }); + } } catch (e) { // this will throw if the user hits cancel, so ignore } @@ -74,7 +77,6 @@ export default class CompleteSecurity extends React.Component { render() { const AuthPage = sdk.getComponent("auth.AuthPage"); - const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); @@ -161,8 +163,7 @@ export default class CompleteSecurity extends React.Component { return ( - - +

{icon} {title} diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.js index 9a078efb52..fe20d76afb 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.js @@ -17,10 +17,25 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; export default class AuthBody extends React.PureComponent { + static PropTypes = { + header: PropTypes.bool, + }; + + static defaultProps = { + header: true, + }; + render() { - return
+ const classes = { + 'mx_AuthBody': true, + 'mx_AuthBody_noHeader': !this.props.header, + }; + + return
{ this.props.children }
; } diff --git a/src/components/views/elements/crypto/VerificationQRCode.js b/src/components/views/elements/crypto/VerificationQRCode.js new file mode 100644 index 0000000000..1cb5647317 --- /dev/null +++ b/src/components/views/elements/crypto/VerificationQRCode.js @@ -0,0 +1,56 @@ +/* +Copyright 2020 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 React from "react"; +import PropTypes from "prop-types"; +import {replaceableComponent} from "../../../../utils/replaceableComponent"; +import * as qs from "qs"; +import QRCode from "qrcode-react"; + +@replaceableComponent("views.elements.crypto.VerificationQRCode") +export default class VerificationQRCode extends React.PureComponent { + static propTypes = { + // Common for all kinds of QR codes + keys: PropTypes.array.isRequired, // array of [Key ID, Base64 Key] pairs + action: PropTypes.string.isRequired, + keyholderUserId: PropTypes.string.isRequired, + + // User verification use case only + secret: PropTypes.string, + otherUserKey: PropTypes.string, // Base64 key being verified + requestEventId: PropTypes.string, + }; + + static defaultProps = { + action: "verify", + }; + + render() { + const query = { + request: this.props.requestEventId, + action: this.props.action, + other_user_key: this.props.otherUserKey, + secret: this.props.secret, + }; + for (const key of this.props.keys) { + query[`key_${key[0]}`] = key[1]; + } + + const uri = `https://matrix.to/#/${this.props.keyholderUserId}?${qs.stringify(query)}`; + + return ; + } +} diff --git a/src/components/views/messages/MKeyVerificationConclusion.js b/src/components/views/messages/MKeyVerificationConclusion.js index f51b97786b..a17dcd8ab0 100644 --- a/src/components/views/messages/MKeyVerificationConclusion.js +++ b/src/components/views/messages/MKeyVerificationConclusion.js @@ -93,7 +93,7 @@ export default class MKeyVerificationConclusion extends React.Component { } if (title) { - const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent); + const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId()); const classes = classNames("mx_EventTile_bubble", "mx_KeyVerification", "mx_KeyVerification_icon", { mx_KeyVerification_icon_verified: request.done, }); diff --git a/src/components/views/messages/MKeyVerificationRequest.js b/src/components/views/messages/MKeyVerificationRequest.js index ae793556d8..49f871d16e 100644 --- a/src/components/views/messages/MKeyVerificationRequest.js +++ b/src/components/views/messages/MKeyVerificationRequest.js @@ -85,7 +85,7 @@ export default class MKeyVerificationRequest extends React.Component { if (userId === myUserId) { return _t("You accepted"); } else { - return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent)}); + return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())}); } } @@ -95,7 +95,7 @@ export default class MKeyVerificationRequest extends React.Component { if (userId === myUserId) { return _t("You cancelled"); } else { - return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent)}); + return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())}); } } @@ -128,10 +128,11 @@ export default class MKeyVerificationRequest extends React.Component { } if (!request.initiatedByMe) { + const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId()); title = (
{ - _t("%(name)s wants to verify", {name: getNameForEventRoom(request.requestingUserId, mxEvent)})}
); + _t("%(name)s wants to verify", {name})}
); subtitle = (
{ - userLabelForEventRoom(request.requestingUserId, mxEvent)}
); + userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId())}); if (request.requested && !request.observeOnly) { stateNode = (
@@ -142,7 +143,7 @@ export default class MKeyVerificationRequest extends React.Component { title = (
{ _t("You sent a verification request")}
); subtitle = (
{ - userLabelForEventRoom(request.receivingUserId, mxEvent)}
); + userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId())}
); } if (title) { diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js index 01d0002801..051f92cc9c 100644 --- a/src/components/views/right_panel/UserInfo.js +++ b/src/components/views/right_panel/UserInfo.js @@ -1237,10 +1237,9 @@ const UserInfo = ({user, groupId, roomId, onClose}) => { let closeButton; if (onClose) { - closeButton = ; + closeButton = +
+ ; } const memberDetails = ( @@ -1356,32 +1355,32 @@ const UserInfo = ({user, groupId, roomId, onClose}) => { return (
- { closeButton } - { avatarElement } - -
-
-
-

- { e2eIcon } - { displayName } -

-
-
{ user.userId }
-
- {presenceLabel} - {statusLabel} -
-
-
- - { memberDetails &&
-
- { memberDetails } -
-
} - + { closeButton } + { avatarElement } + +
+
+
+

+ { e2eIcon } + { displayName } +

+
+
{ user.userId }
+
+ {presenceLabel} + {statusLabel} +
+
+
+ + { memberDetails &&
+
+ { memberDetails } +
+
} + { securitySection } Waiting for {request.otherUserId} to accept ...

); @@ -44,6 +48,23 @@ export default class VerificationPanel extends React.PureComponent { const verifyButton = Verify by emoji ; + + if (request.requestEvent && request.requestEvent.getId()) { + const qrCodeKeys = [ + [MatrixClientPeg.get().getDeviceId(), MatrixClientPeg.get().getDeviceEd25519Key()], + [MatrixClientPeg.get().getCrossSigningId(), MatrixClientPeg.get().getCrossSigningId()], + ]; + const crossSigningInfo = MatrixClientPeg.get().getStoredCrossSigningForUser(request.otherUserId); + const qrCode = ; + return (

{request.otherUserId} is ready, start {verifyButton} or have them scan: {qrCode}

); + } + return (

{request.otherUserId} is ready, start {verifyButton}

); } else if (request.started) { if (this.state.sasWaitingForOtherParty) { diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js index 545d1fd7ed..df5fe204d4 100644 --- a/src/components/views/rooms/E2EIcon.js +++ b/src/components/views/rooms/E2EIcon.js @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2020 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. @@ -14,76 +15,102 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React, {useState} from "react"; +import PropTypes from "prop-types"; import classNames from 'classnames'; -import { _t } from '../../../languageHandler'; -import AccessibleButton from '../elements/AccessibleButton'; -import SettingsStore from '../../../settings/SettingsStore'; -export default function(props) { - const { isUser } = props; - const isNormal = props.status === "normal"; - const isWarning = props.status === "warning"; - const isVerified = props.status === "verified"; - const e2eIconClasses = classNames({ +import {_t, _td} from '../../../languageHandler'; +import {useFeatureEnabled} from "../../../hooks/useSettings"; +import AccessibleButton from "../elements/AccessibleButton"; +import Tooltip from "../elements/Tooltip"; + +export const E2E_STATE = { + VERIFIED: "verified", + WARNING: "warning", + UNKNOWN: "unknown", + NORMAL: "normal", +}; + +const crossSigningUserTitles = { + [E2E_STATE.WARNING]: _td("This user has not verified all of their devices."), + [E2E_STATE.NORMAL]: _td("You have not verified this user. This user has verified all of their devices."), + [E2E_STATE.VERIFIED]: _td("You have verified this user. This user has verified all of their devices."), +}; +const crossSigningRoomTitles = { + [E2E_STATE.WARNING]: _td("Someone is using an unknown device"), + [E2E_STATE.NORMAL]: _td("This room is end-to-end encrypted"), + [E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"), +}; + +const legacyUserTitles = { + [E2E_STATE.WARNING]: _td("Some devices for this user are not trusted"), + [E2E_STATE.VERIFIED]: _td("All devices for this user are trusted"), +}; +const legacyRoomTitles = { + [E2E_STATE.WARNING]: _td("Some devices in this encrypted room are not trusted"), + [E2E_STATE.VERIFIED]: _td("All devices in this encrypted room are trusted"), +}; + +const E2EIcon = ({isUser, status, className, size, onClick}) => { + const [hover, setHover] = useState(false); + + const classes = classNames({ mx_E2EIcon: true, - mx_E2EIcon_warning: isWarning, - mx_E2EIcon_normal: isNormal, - mx_E2EIcon_verified: isVerified, - }, props.className); + mx_E2EIcon_warning: status === E2E_STATE.WARNING, + mx_E2EIcon_normal: status === E2E_STATE.NORMAL, + mx_E2EIcon_verified: status === E2E_STATE.VERIFIED, + }, className); + let e2eTitle; - - const crossSigning = SettingsStore.isFeatureEnabled("feature_cross_signing"); + const crossSigning = useFeatureEnabled("feature_cross_signing"); if (crossSigning && isUser) { - if (isWarning) { - e2eTitle = _t( - "This user has not verified all of their devices.", - ); - } else if (isNormal) { - e2eTitle = _t( - "You have not verified this user. " + - "This user has verified all of their devices.", - ); - } else if (isVerified) { - e2eTitle = _t( - "You have verified this user. " + - "This user has verified all of their devices.", - ); - } + e2eTitle = crossSigningUserTitles[status]; } else if (crossSigning && !isUser) { - if (isWarning) { - e2eTitle = _t( - "Some users in this encrypted room are not verified by you or " + - "they have not verified their own devices.", - ); - } else if (isVerified) { - e2eTitle = _t( - "All users in this encrypted room are verified by you and " + - "they have verified their own devices.", - ); - } + e2eTitle = crossSigningRoomTitles[status]; } else if (!crossSigning && isUser) { - if (isWarning) { - e2eTitle = _t("Some devices for this user are not trusted"); - } else if (isVerified) { - e2eTitle = _t("All devices for this user are trusted"); - } + e2eTitle = legacyUserTitles[status]; } else if (!crossSigning && !isUser) { - if (isWarning) { - e2eTitle = _t("Some devices in this encrypted room are not trusted"); - } else if (isVerified) { - e2eTitle = _t("All devices in this encrypted room are trusted"); - } + e2eTitle = legacyRoomTitles[status]; } - let style = null; - if (props.size) { - style = {width: `${props.size}px`, height: `${props.size}px`}; + let style; + if (size) { + style = {width: `${size}px`, height: `${size}px`}; } - const icon = (
); - if (props.onClick) { - return ({ icon }); - } else { - return icon; + const onMouseOver = () => setHover(true); + const onMouseOut = () => setHover(false); + + let tip; + if (hover) { + tip = ; } -} + + if (onClick) { + return ( + + { tip } + + ); + } + + return
+ { tip } +
; +}; + +E2EIcon.propTypes = { + isUser: PropTypes.bool, + status: PropTypes.oneOf(Object.values(E2E_STATE)), + className: PropTypes.string, + size: PropTypes.number, + onClick: PropTypes.func, +}; + +export default E2EIcon; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 634b77c9e1..940515f02e 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -33,6 +33,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {ALL_RULE_TYPES} from "../../../mjolnir/BanList"; import * as ObjectUtils from "../../../ObjectUtils"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {E2E_STATE} from "./E2EIcon"; const eventTileTypes = { 'm.room.message': 'messages.MessageEvent', @@ -66,13 +67,6 @@ const stateEventTileTypes = { 'm.room.related_groups': 'messages.TextualEvent', }; -const E2E_STATE = { - VERIFIED: "verified", - WARNING: "warning", - UNKNOWN: "unknown", - NORMAL: "normal", -}; - // Add all the Mjolnir stuff to the renderer for (const evType of ALL_RULE_TYPES) { stateEventTileTypes[evType] = 'messages.TextualEvent'; diff --git a/src/components/views/rooms/InviteOnlyIcon.js b/src/components/views/rooms/InviteOnlyIcon.js new file mode 100644 index 0000000000..5afaa7f0f2 --- /dev/null +++ b/src/components/views/rooms/InviteOnlyIcon.js @@ -0,0 +1,51 @@ +/* +Copyright 2020 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 React from 'react'; +import { _t } from '../../../languageHandler'; +import * as sdk from '../../../index'; + +export default class InviteOnlyIcon extends React.Component { + constructor() { + super(); + + this.state = { + hover: false, + }; + } + + onHoverStart = () => { + this.setState({hover: true}); + }; + + onHoverEnd = () => { + this.setState({hover: false}); + }; + + render() { + const Tooltip = sdk.getComponent("elements.Tooltip"); + let tooltip; + if (this.state.hover) { + tooltip = ; + } + return (
+ { tooltip } +
); + } +} diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 8d36f02d02..53e10fa750 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -26,6 +26,7 @@ import Stickerpicker from './Stickerpicker'; import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks'; import ContentMessages from '../../../ContentMessages'; import E2EIcon from './E2EIcon'; +import SettingsStore from "../../../settings/SettingsStore"; function ComposerAvatar(props) { const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); @@ -168,7 +169,6 @@ export default class MessageComposer extends React.Component { constructor(props) { super(props); this.onInputStateChanged = this.onInputStateChanged.bind(this); - this.onEvent = this.onEvent.bind(this); this._onRoomStateEvents = this._onRoomStateEvents.bind(this); this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this); @@ -182,11 +182,6 @@ export default class MessageComposer extends React.Component { } componentDidMount() { - // N.B. using 'event' rather than 'RoomEvents' otherwise the crypto handler - // for 'event' fires *after* 'RoomEvent', and our room won't have yet been - // marked as encrypted. - // XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something. - MatrixClientPeg.get().on("event", this.onEvent); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._waitForOwnMember(); @@ -210,7 +205,6 @@ export default class MessageComposer extends React.Component { componentWillUnmount() { if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener("event", this.onEvent); MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); } if (this._roomStoreToken) { @@ -218,13 +212,6 @@ export default class MessageComposer extends React.Component { } } - onEvent(event) { - if (event.getType() !== 'm.room.encryption') return; - if (event.getRoomId() !== this.props.room.roomId) return; - // TODO: put (encryption state??) in state - this.forceUpdate(); - } - _onRoomStateEvents(ev, state) { if (ev.getRoomId() !== this.props.room.roomId) return; @@ -282,18 +269,33 @@ export default class MessageComposer extends React.Component { } renderPlaceholderText() { - const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); - if (this.state.isQuoting) { - if (roomIsEncrypted) { - return _t('Send an encrypted reply…'); + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (this.state.isQuoting) { + if (this.props.e2eStatus) { + return _t('Send an encrypted reply…'); + } else { + return _t('Send a reply…'); + } } else { - return _t('Send a reply (unencrypted)…'); + if (this.props.e2eStatus) { + return _t('Send an encrypted message…'); + } else { + return _t('Send a message…'); + } } } else { - if (roomIsEncrypted) { - return _t('Send an encrypted message…'); + if (this.state.isQuoting) { + if (this.props.e2eStatus) { + return _t('Send an encrypted reply…'); + } else { + return _t('Send a reply (unencrypted)…'); + } } else { - return _t('Send a message (unencrypted)…'); + if (this.props.e2eStatus) { + return _t('Send an encrypted message…'); + } else { + return _t('Send a message (unencrypted)…'); + } } } } diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 15f0daa200..8a427e1c06 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -32,6 +32,7 @@ import {CancelButton} from './SimpleRoomHeader'; import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; +import InviteOnlyIcon from './InviteOnlyIcon'; export default createReactClass({ displayName: 'RoomHeader', @@ -162,11 +163,12 @@ export default createReactClass({ const joinRules = this.props.room && this.props.room.currentState.getStateEvents("m.room.join_rules", ""); const joinRule = joinRules && joinRules.getContent().join_rule; - const joinRuleClass = classNames("mx_RoomHeader_PrivateIcon", - {"mx_RoomHeader_isPrivate": joinRule === "invite"}); - const privateIcon = SettingsStore.isFeatureEnabled("feature_cross_signing") ? -
: - undefined; + let privateIcon; + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (joinRule == "invite") { + privateIcon = ; + } + } if (this.props.onCancelClick) { cancelButton = ; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index ee3100b535..f41400ecfc 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -719,7 +719,7 @@ export default createReactClass({ }, { list: this.state.lists['im.vector.fake.direct'], - label: _t('People'), + label: _t('Direct Messages'), tagName: "im.vector.fake.direct", order: "recent", incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'), diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index f4f5fa10fc..9d2334de82 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -33,6 +33,10 @@ import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; import {_t} from "../../../languageHandler"; import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; +import E2EIcon from './E2EIcon'; +import InviteOnlyIcon from './InviteOnlyIcon'; +// eslint-disable-next-line camelcase +import rate_limited_func from '../../../ratelimitedfunc'; export default createReactClass({ displayName: 'RoomTile', @@ -70,6 +74,7 @@ export default createReactClass({ notificationCount: this.props.room.getUnreadNotificationCount(), selected: this.props.room.roomId === RoomViewStore.getRoomId(), statusMessage: this._getStatusMessage(), + e2eStatus: null, }); }, @@ -102,6 +107,83 @@ export default createReactClass({ return statusUser._unstable_statusMessage; }, + onRoomStateMember: function(ev, state, member) { + // we only care about leaving users + // because trust state will change if someone joins a megolm session anyway + if (member.membership !== "leave") { + return; + } + // ignore members in other rooms + if (member.roomId !== this.props.room.roomId) { + return; + } + + this._updateE2eStatus(); + }, + + onUserVerificationChanged: function(userId, _trustStatus) { + if (!this.props.room.getMember(userId)) { + // Not in this room + return; + } + this._updateE2eStatus(); + }, + + onRoomTimeline: function(ev, room) { + if (!room) return; + if (room.roomId != this.props.room.roomId) return; + if (ev.getType() !== "m.room.encryption") return; + MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); + this.onFindingRoomToBeEncrypted(); + }, + + onFindingRoomToBeEncrypted: function() { + const cli = MatrixClientPeg.get(); + cli.on("RoomState.members", this.onRoomStateMember); + cli.on("userTrustStatusChanged", this.onUserVerificationChanged); + + this._updateE2eStatus(); + }, + + _updateE2eStatus: async function() { + const cli = MatrixClientPeg.get(); + if (!cli.isRoomEncrypted(this.props.room.roomId)) { + return; + } + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) { + return; + } + + // Duplication between here and _updateE2eStatus in RoomView + const e2eMembers = await this.props.room.getEncryptionTargetMembers(); + const verified = []; + const unverified = []; + e2eMembers.map(({userId}) => userId) + .filter((userId) => userId !== cli.getUserId()) + .forEach((userId) => { + (cli.checkUserTrust(userId).isCrossSigningVerified() ? + verified : unverified).push(userId); + }); + + /* Check all verified user devices. */ + for (const userId of verified) { + const devices = await cli.getStoredDevicesForUser(userId); + const allDevicesVerified = devices.every(({deviceId}) => { + return cli.checkDeviceTrust(userId, deviceId).isVerified(); + }); + if (!allDevicesVerified) { + this.setState({ + e2eStatus: "warning", + }); + return; + } + } + + this.setState({ + e2eStatus: unverified.length === 0 ? "verified" : "normal", + }); + }, + onRoomName: function(room) { if (room !== this.props.room) return; this.setState({ @@ -151,10 +233,19 @@ export default createReactClass({ }, componentDidMount: function() { + /* We bind here rather than in the definition because otherwise we wind up with the + method only being callable once every 500ms across all instances, which would be wrong */ + this._updateE2eStatus = rate_limited_func(this._updateE2eStatus, 500); + const cli = MatrixClientPeg.get(); cli.on("accountData", this.onAccountData); cli.on("Room.name", this.onRoomName); cli.on("RoomState.events", this.onJoinRule); + if (cli.isRoomEncrypted(this.props.room.roomId)) { + this.onFindingRoomToBeEncrypted(); + } else { + cli.on("Room.timeline", this.onRoomTimeline); + } ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange); this.dispatcherRef = dis.register(this.onAction); @@ -172,6 +263,9 @@ export default createReactClass({ MatrixClientPeg.get().removeListener("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); cli.removeListener("RoomState.events", this.onJoinRule); + cli.removeListener("RoomState.members", this.onRoomStateMember); + cli.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); + cli.removeListener("Room.timeline", this.onRoomTimeline); } ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange); dis.unregister(this.dispatcherRef); @@ -318,7 +412,6 @@ export default createReactClass({ 'mx_RoomTile_noBadges': !badges, 'mx_RoomTile_transparent': this.props.transparent, 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed, - 'mx_RoomTile_isPrivate': this.state.joinRule == "invite" && !dmUserId, }); const avatarClasses = classNames({ @@ -385,7 +478,8 @@ export default createReactClass({ let dmIndicator; let dmOnline; - if (dmUserId) { + // If we can place a shield, do that instead + if (dmUserId && !this.state.e2eStatus) { dmIndicator = ; + if (this.state.joinRule == "invite" && !dmUserId) { + privateIcon = ; + } + } + + let e2eIcon = null; + if (this.state.e2eStatus) { + e2eIcon = ; } return @@ -453,6 +554,7 @@ export default createReactClass({
{ dmIndicator } + { e2eIcon }
{ privateIcon } diff --git a/src/components/views/toasts/VerificationRequestToast.js b/src/components/views/toasts/VerificationRequestToast.js index 479a3e3f93..f912984486 100644 --- a/src/components/views/toasts/VerificationRequestToast.js +++ b/src/components/views/toasts/VerificationRequestToast.js @@ -23,6 +23,7 @@ import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases"; import {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver"; import dis from "../../../dispatcher"; import ToastStore from "../../../stores/ToastStore"; +import Modal from "../../../Modal"; export default class VerificationRequestToast extends React.PureComponent { constructor(props) { @@ -65,22 +66,27 @@ export default class VerificationRequestToast extends React.PureComponent { accept = async () => { ToastStore.sharedInstance().dismissToast(this.props.toastKey); const {request} = this.props; - const {event} = request; // no room id for to_device requests - if (event.getRoomId()) { - dis.dispatch({ - action: 'view_room', - room_id: event.getRoomId(), - should_peek: false, - }); - } try { - await request.accept(); - dis.dispatch({ - action: "set_right_panel_phase", - phase: RIGHT_PANEL_PHASES.EncryptionPanel, - refireParams: {verificationRequest: request}, - }); + if (request.channel.roomId) { + dis.dispatch({ + action: 'view_room', + room_id: request.channel.roomId, + should_peek: false, + }); + await request.accept(); + dis.dispatch({ + action: "set_right_panel_phase", + phase: RIGHT_PANEL_PHASES.EncryptionPanel, + refireParams: {verificationRequest: request}, + }); + } else if (request.channel.deviceId && request.verifier) { + // show to_device verifications in dialog still + const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog"); + Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { + verifier: request.verifier, + }, null, /* priority = */ false, /* static = */ true); + } } catch (err) { console.error(err.message); } @@ -89,13 +95,13 @@ export default class VerificationRequestToast extends React.PureComponent { render() { const FormButton = sdk.getComponent("elements.FormButton"); const {request} = this.props; - const {event} = request; const userId = request.otherUserId; - let nameLabel = event.getRoomId() ? userLabelForEventRoom(userId, event) : userId; + const roomId = request.channel.roomId; + let nameLabel = roomId ? userLabelForEventRoom(userId, roomId) : userId; // for legacy to_device verification requests if (nameLabel === userId) { const client = MatrixClientPeg.get(); - const user = client.getUser(event.getSender()); + const user = client.getUser(userId); if (user && user.displayName) { nameLabel = _t("%(name)s (%(userId)s)", {name: user.displayName, userId}); } diff --git a/src/hooks/useSettings.js b/src/hooks/useSettings.js new file mode 100644 index 0000000000..151a6369de --- /dev/null +++ b/src/hooks/useSettings.js @@ -0,0 +1,52 @@ +/* +Copyright 2020 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 {useEffect, useState} from "react"; +import SettingsStore from '../settings/SettingsStore'; + +// Hook to fetch the value of a setting and dynamically update when it changes +export const useSettingValue = (settingName, roomId = null, excludeDefault = false) => { + const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId, excludeDefault)); + + useEffect(() => { + const ref = SettingsStore.watchSetting(settingName, roomId, () => { + setValue(SettingsStore.getValue(settingName, roomId, excludeDefault)); + }); + // clean-up + return () => { + SettingsStore.unwatchSetting(ref); + }; + }, [settingName, roomId, excludeDefault]); + + return value; +}; + +// Hook to fetch whether a feature is enabled and dynamically update when that changes +export const useFeatureEnabled = (featureName, roomId = null) => { + const [enabled, setEnabled] = useState(SettingsStore.isFeatureEnabled(featureName, roomId)); + + useEffect(() => { + const ref = SettingsStore.watchSetting(featureName, roomId, () => { + setEnabled(SettingsStore.isFeatureEnabled(featureName, roomId)); + }); + // clean-up + return () => { + SettingsStore.unwatchSetting(ref); + }; + }, [featureName, roomId]); + + return enabled; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e59805ccd7..3c758ecbfb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -21,6 +21,9 @@ "Analytics": "Analytics", "The information being sent to us to help make Riot.im better includes:": "The information being sent to us to help make Riot.im better includes:", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.", + "Error": "Error", + "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", + "Dismiss": "Dismiss", "Call Failed": "Call Failed", "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.": "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.", "Review Devices": "Review Devices", @@ -105,9 +108,6 @@ "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server to validate an email address or phone number, but the server 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.", "Trust": "Trust", - "Error": "Error", - "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", - "Dismiss": "Dismiss", "Riot does not have permission to send you notifications - please check your browser settings": "Riot does not have permission to send you notifications - please check your browser settings", "Riot was not given permission to send notifications - please try again": "Riot was not given permission to send notifications - please try again", "Unable to enable Notifications": "Unable to enable Notifications", @@ -887,8 +887,9 @@ "This user has not verified all of their devices.": "This user has not verified all of their devices.", "You have not verified this user. This user has verified all of their devices.": "You have not verified this user. This user has verified all of their devices.", "You have verified this user. This user has verified all of their devices.": "You have verified this user. This user has verified all of their devices.", - "Some users in this encrypted room are not verified by you or they have not verified their own devices.": "Some users in this encrypted room are not verified by you or they have not verified their own devices.", - "All users in this encrypted room are verified by you and they have verified their own devices.": "All users in this encrypted room are verified by you and they have verified their own devices.", + "Someone is using an unknown device": "Someone is using an unknown device", + "This room is end-to-end encrypted": "This room is end-to-end encrypted", + "Everyone in this room is verified": "Everyone in this room is verified", "Some devices for this user are not trusted": "Some devices for this user are not trusted", "All devices for this user are trusted": "All devices for this user are trusted", "Some devices in this encrypted room are not trusted": "Some devices in this encrypted room are not trusted", @@ -908,6 +909,7 @@ "Unencrypted": "Unencrypted", "Encrypted by a deleted device": "Encrypted by a deleted device", "Please select the destination room for this message": "Please select the destination room for this message", + "Invite only": "Invite only", "Scroll to bottom of page": "Scroll to bottom of page", "Close preview": "Close preview", "device id: ": "device id: ", @@ -964,8 +966,10 @@ "Hangup": "Hangup", "Upload file": "Upload file", "Send an encrypted reply…": "Send an encrypted reply…", - "Send a reply (unencrypted)…": "Send a reply (unencrypted)…", + "Send a reply…": "Send a reply…", "Send an encrypted message…": "Send an encrypted message…", + "Send a message…": "Send a message…", + "Send a reply (unencrypted)…": "Send a reply (unencrypted)…", "Send a message (unencrypted)…": "Send a message (unencrypted)…", "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", @@ -1012,7 +1016,7 @@ "Community Invites": "Community Invites", "Invites": "Invites", "Favourites": "Favourites", - "People": "People", + "Direct Messages": "Direct Messages", "Start chat": "Start chat", "Rooms": "Rooms", "Low priority": "Low priority", diff --git a/src/indexing/BaseEventIndexManager.js b/src/indexing/BaseEventIndexManager.js index 5e8ca668ad..c4758bcaa3 100644 --- a/src/indexing/BaseEventIndexManager.js +++ b/src/indexing/BaseEventIndexManager.js @@ -62,11 +62,18 @@ export interface SearchArgs { room_id: ?string; } -export interface HistoricEvent { +export interface EventAndProfile { event: MatrixEvent; profile: MatrixProfile; } +export interface LoadArgs { + roomId: string; + limit: number; + fromEvent: string; + direction: string; +} + /** * Base class for classes that provide platform-specific event indexing. * @@ -145,7 +152,7 @@ export default class BaseEventIndexManager { * * This is used to add a batch of events to the index. * - * @param {[HistoricEvent]} events The list of events and profiles that + * @param {[EventAndProfile]} events The list of events and profiles that * should be added to the event index. * @param {[CrawlerCheckpoint]} checkpoint A new crawler checkpoint that * should be stored in the index which should be used to continue crawling @@ -158,7 +165,7 @@ export default class BaseEventIndexManager { * were already added to the index, false otherwise. */ async addHistoricEvents( - events: [HistoricEvent], + events: [EventAndProfile], checkpoint: CrawlerCheckpoint | null, oldCheckpoint: CrawlerCheckpoint | null, ): Promise { @@ -201,6 +208,26 @@ export default class BaseEventIndexManager { throw new Error("Unimplemented"); } + /** Load events that contain an mxc URL to a file from the index. + * + * @param {object} args Arguments object for the method. + * @param {string} args.roomId The ID of the room for which the events + * should be loaded. + * @param {number} args.limit The maximum number of events to return. + * @param {string} args.fromEvent An event id of a previous event returned + * by this method. Passing this means that we are going to continue loading + * events from this point in the history. + * @param {string} args.direction The direction to which we should continue + * loading events from. This is used only if fromEvent is used as well. + * + * @return {Promise<[EventAndProfile]>} A promise that will resolve to an + * array of Matrix events that contain mxc URLs accompanied with the + * historic profile of the sender. + */ + async loadFileEvents(args: LoadArgs): Promise<[EventAndProfile]> { + throw new Error("Unimplemented"); + } + /** * close our event index. * diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.js index c912e31fa5..b6e29c455d 100644 --- a/src/indexing/EventIndex.js +++ b/src/indexing/EventIndex.js @@ -16,6 +16,7 @@ limitations under the License. import PlatformPeg from "../PlatformPeg"; import {MatrixClientPeg} from "../MatrixClientPeg"; +import {EventTimeline, RoomMember} from 'matrix-js-sdk'; /* * Event indexing class that wraps the platform specific event indexing. @@ -170,7 +171,9 @@ export default class EventIndex { return; } - const e = ev.toJSON().decrypted; + const jsonEvent = ev.toJSON(); + const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent; + const profile = { displayname: ev.sender.rawDisplayName, avatar_url: ev.sender.getMxcAvatarUrl(), @@ -305,10 +308,7 @@ export default class EventIndex { // consume. const events = filteredEvents.map((ev) => { const jsonEvent = ev.toJSON(); - - let e; - if (ev.isEncrypted()) e = jsonEvent.decrypted; - else e = jsonEvent; + const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent; let profile = {}; if (e.sender in profiles) profile = profiles[e.sender]; @@ -406,4 +406,198 @@ export default class EventIndex { const indexManager = PlatformPeg.get().getEventIndexingManager(); return indexManager.searchEventIndex(searchArgs); } + + /** + * Load events that contain URLs from the event index. + * + * @param {Room} room The room for which we should fetch events containing + * URLs + * + * @param {number} limit The maximum number of events to fetch. + * + * @param {string} fromEvent From which event should we continue fetching + * events from the index. This is only needed if we're continuing to fill + * the timeline, e.g. if we're paginating. This needs to be set to a event + * id of an event that was previously fetched with this function. + * + * @param {string} direction The direction in which we will continue + * fetching events. EventTimeline.BACKWARDS to continue fetching events that + * are older than the event given in fromEvent, EventTimeline.FORWARDS to + * fetch newer events. + * + * @returns {Promise} Resolves to an array of events that + * contain URLs. + */ + async loadFileEvents(room, limit = 10, fromEvent = null, direction = EventTimeline.BACKWARDS) { + const client = MatrixClientPeg.get(); + const indexManager = PlatformPeg.get().getEventIndexingManager(); + + const loadArgs = { + roomId: room.roomId, + limit: limit, + }; + + if (fromEvent) { + loadArgs.fromEvent = fromEvent; + loadArgs.direction = direction; + } + + let events; + + // Get our events from the event index. + try { + events = await indexManager.loadFileEvents(loadArgs); + } catch (e) { + console.log("EventIndex: Error getting file events", e); + return []; + } + + const eventMapper = client.getEventMapper(); + + // Turn the events into MatrixEvent objects. + const matrixEvents = events.map(e => { + const matrixEvent = eventMapper(e.event); + + const member = new RoomMember(room.roomId, matrixEvent.getSender()); + + // We can't really reconstruct the whole room state from our + // EventIndex to calculate the correct display name. Use the + // disambiguated form always instead. + member.name = e.profile.displayname + " (" + matrixEvent.getSender() + ")"; + + // This is sets the avatar URL. + const memberEvent = eventMapper( + { + content: { + membership: "join", + avatar_url: e.profile.avatar_url, + displayname: e.profile.displayname, + }, + type: "m.room.member", + event_id: matrixEvent.getId() + ":eventIndex", + room_id: matrixEvent.getRoomId(), + sender: matrixEvent.getSender(), + origin_server_ts: matrixEvent.getTs(), + state_key: matrixEvent.getSender(), + }, + ); + + // We set this manually to avoid emitting RoomMember.membership and + // RoomMember.name events. + member.events.member = memberEvent; + matrixEvent.sender = member; + + return matrixEvent; + }); + + return matrixEvents; + } + + /** + * Fill a timeline with events that contain URLs. + * + * @param {TimelineSet} timelineSet The TimelineSet the Timeline belongs to, + * used to check if we're adding duplicate events. + * + * @param {Timeline} timeline The Timeline which should be filed with + * events. + * + * @param {Room} room The room for which we should fetch events containing + * URLs + * + * @param {number} limit The maximum number of events to fetch. + * + * @param {string} fromEvent From which event should we continue fetching + * events from the index. This is only needed if we're continuing to fill + * the timeline, e.g. if we're paginating. This needs to be set to a event + * id of an event that was previously fetched with this function. + * + * @param {string} direction The direction in which we will continue + * fetching events. EventTimeline.BACKWARDS to continue fetching events that + * are older than the event given in fromEvent, EventTimeline.FORWARDS to + * fetch newer events. + * + * @returns {Promise} Resolves to true if events were added to the + * timeline, false otherwise. + */ + async populateFileTimeline(timelineSet, timeline, room, limit = 10, + fromEvent = null, direction = EventTimeline.BACKWARDS) { + const matrixEvents = await this.loadFileEvents(room, limit, fromEvent, direction); + + // If this is a normal fill request, not a pagination request, we need + // to get our events in the BACKWARDS direction but populate them in the + // forwards direction. + // This needs to happen because a fill request might come with an + // exisitng timeline e.g. if you close and re-open the FilePanel. + if (fromEvent === null) { + matrixEvents.reverse(); + direction = direction == EventTimeline.BACKWARDS ? EventTimeline.FORWARDS: EventTimeline.BACKWARDS; + } + + // Add the events to the timeline of the file panel. + matrixEvents.forEach(e => { + if (!timelineSet.eventIdToTimeline(e.getId())) { + timelineSet.addEventToTimeline(e, timeline, direction == EventTimeline.BACKWARDS); + } + }); + + // Set the pagination token to the oldest event that we retrieved. + if (matrixEvents.length > 0) { + timeline.setPaginationToken(matrixEvents[matrixEvents.length - 1].getId(), EventTimeline.BACKWARDS); + return true; + } else { + timeline.setPaginationToken("", EventTimeline.BACKWARDS); + return false; + } + } + + /** + * Emulate a TimelineWindow pagination() request with the event index as the event source + * + * Might not fetch events from the index if the timeline already contains + * events that the window isn't showing. + * + * @param {Room} room The room for which we should fetch events containing + * URLs + * + * @param {TimelineWindow} timelineWindow The timeline window that should be + * populated with new events. + * + * @param {string} direction The direction in which we should paginate. + * EventTimeline.BACKWARDS to paginate back, EventTimeline.FORWARDS to + * paginate forwards. + * + * @param {number} limit The maximum number of events to fetch while + * paginating. + * + * @returns {Promise} Resolves to a boolean which is true if more + * events were successfully retrieved. + */ + paginateTimelineWindow(room, timelineWindow, direction, limit) { + const tl = timelineWindow.getTimelineIndex(direction); + + if (!tl) return Promise.resolve(false); + if (tl.pendingPaginate) return tl.pendingPaginate; + + if (timelineWindow.extend(direction, limit)) { + return Promise.resolve(true); + } + + const paginationMethod = async (timelineWindow, timeline, room, direction, limit) => { + const timelineSet = timelineWindow._timelineSet; + const token = timeline.timeline.getPaginationToken(direction); + + const ret = await this.populateFileTimeline(timelineSet, timeline.timeline, room, limit, token, direction); + + timeline.pendingPaginate = null; + timelineWindow.extend(direction, limit); + + return ret; + }; + + const paginationPromise = paginationMethod(timelineWindow, tl, room, direction, limit); + tl.pendingPaginate = paginationPromise; + + return paginationPromise; + } } diff --git a/src/utils/KeyVerificationStateObserver.js b/src/utils/KeyVerificationStateObserver.js index 1a35319186..a29d2ea1aa 100644 --- a/src/utils/KeyVerificationStateObserver.js +++ b/src/utils/KeyVerificationStateObserver.js @@ -17,16 +17,15 @@ limitations under the License. import {MatrixClientPeg} from '../MatrixClientPeg'; import { _t } from '../languageHandler'; -export function getNameForEventRoom(userId, mxEvent) { - const roomId = mxEvent.getRoomId(); +export function getNameForEventRoom(userId, roomId) { const client = MatrixClientPeg.get(); const room = client.getRoom(roomId); - const member = room.getMember(userId); + const member = room && room.getMember(userId); return member ? member.name : userId; } -export function userLabelForEventRoom(userId, mxEvent) { - const name = getNameForEventRoom(userId, mxEvent); +export function userLabelForEventRoom(userId, roomId) { + const name = getNameForEventRoom(userId, roomId); if (name !== userId) { return _t("%(name)s (%(userId)s)", {name, userId}); } else {