From 1c1d5ea5a90dc3606e4901b1f71af22998700b0a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 14 Apr 2022 12:07:43 +0100 Subject: [PATCH 01/50] [Release] Fixes around threads beta in degraded mode (#8319) * Fix soft crash around the thread panel in degraded mode * Better specify fallback key * Hide MAB Threads prompt if user would have degraded mode * Confirm user wants to enable Threads beta if in degraded mode * Fix copy --- src/components/structures/ThreadPanel.tsx | 2 +- .../views/messages/MessageActionBar.tsx | 6 +++ src/i18n/strings/en_EN.json | 4 ++ src/settings/Settings.tsx | 5 +- src/settings/SettingsStore.ts | 9 ++-- src/settings/controllers/SettingController.ts | 11 ++++ .../controllers/ThreadBetaController.tsx | 53 +++++++++++++++++++ 7 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 src/settings/controllers/ThreadBetaController.tsx diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 7d75f918589..bc2c53d2035 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -286,7 +286,7 @@ const ThreadPanel: React.FC = ({ /> { timelineSet && ( Learn more.": "Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. Learn more.", + "Do you want to enable threads anyway?": "Do you want to enable threads anyway?", + "Yes, enable": "Yes, enable", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading logs": "Uploading logs", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 8cd842b74e5..bd34297161b 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -42,6 +42,7 @@ import IncompatibleController from "./controllers/IncompatibleController"; import { ImageSize } from "./enums/ImageSize"; import { MetaSpace } from "../stores/spaces"; import SdkConfig from "../SdkConfig"; +import ThreadBetaController from './controllers/ThreadBetaController'; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -222,9 +223,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { "feature_thread": { isFeature: true, labsGroup: LabGroup.Messaging, - // Requires a reload as we change an option flag on the `js-sdk` - // And the entire sync history needs to be parsed again - controller: new ReloadOnChangeController(), + controller: new ThreadBetaController(), displayName: _td("Threaded messaging"), supportedLevels: LEVELS_FEATURE, default: false, diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index ca9bfe3703e..95ea0e6993e 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -451,12 +451,13 @@ export default class SettingsStore { throw new Error("User cannot set " + settingName + " at " + level + " in " + roomId); } + if (setting.controller && !(await setting.controller.beforeChange(level, roomId, value))) { + return; // controller says no + } + await handler.setValue(settingName, roomId, value); - const controller = setting.controller; - if (controller) { - controller.onChange(level, roomId, value); - } + setting.controller?.onChange(level, roomId, value); } /** diff --git a/src/settings/controllers/SettingController.ts b/src/settings/controllers/SettingController.ts index 292b2d63e59..a274bcff2c3 100644 --- a/src/settings/controllers/SettingController.ts +++ b/src/settings/controllers/SettingController.ts @@ -46,6 +46,17 @@ export default abstract class SettingController { return null; // no override } + /** + * Called before the setting value has been changed, can abort the change. + * @param {string} level The level at which the setting has been modified. + * @param {String} roomId The room ID, may be null. + * @param {*} newValue The new value for the setting, may be null. + * @return {boolean} Whether the settings change should be accepted. + */ + public async beforeChange(level: SettingLevel, roomId: string, newValue: any): Promise { + return true; + } + /** * Called when the setting value has been changed. * @param {string} level The level at which the setting has been modified. diff --git a/src/settings/controllers/ThreadBetaController.tsx b/src/settings/controllers/ThreadBetaController.tsx new file mode 100644 index 00000000000..487d2013b17 --- /dev/null +++ b/src/settings/controllers/ThreadBetaController.tsx @@ -0,0 +1,53 @@ +/* +Copyright 2022 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 * as React from "react"; +import { Thread } from "matrix-js-sdk/src/models/thread"; + +import SettingController from "./SettingController"; +import PlatformPeg from "../../PlatformPeg"; +import { SettingLevel } from "../SettingLevel"; +import Modal from "../../Modal"; +import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; +import { _t } from "../../languageHandler"; + +export default class ThreadBetaController extends SettingController { + public async beforeChange(level: SettingLevel, roomId: string, newValue: any): Promise { + if (Thread.hasServerSideSupport || !newValue) return true; // Full support or user is disabling + + const { finished } = Modal.createTrackedDialog<[boolean]>("Thread beta", "degraded mode", QuestionDialog, { + title: _t("Partial Support for Threads"), + description: <> +

{ _t("Your homeserver does not currently support threads, so this feature may be unreliable. " + + "Some threaded messages may not be reliably available. Learn more.", {}, { + a: sub => ( + { sub } + ), + }) }

+

{ _t("Do you want to enable threads anyway?") }

+ , + button: _t("Yes, enable"), + }); + const [enable] = await finished; + return enable; + } + + public onChange(level: SettingLevel, roomId: string, newValue: any) { + // Requires a reload as we change an option flag on the `js-sdk` + // And the entire sync history needs to be parsed again + PlatformPeg.get().reload(); + } +} From b91429fdf995a0cf4ac68757157aa9fb23d69a03 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Thu, 14 Apr 2022 13:56:13 +0100 Subject: [PATCH 02/50] Prepare changelog for v3.42.4 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0126f1f369f..62f25872eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [3.42.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.4) (2022-04-14) +===================================================================================================== + +## 🐛 Bug Fixes + * Fixes around threads beta in degraded mode ([\#8319](https://github.com/matrix-org/matrix-react-sdk/pull/8319)). Fixes vector-im/element-web#21762. + Changes in [3.42.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.3) (2022-04-12) ===================================================================================================== From b6155e56c09ce488a3b40524a79a89395323b317 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Thu, 14 Apr 2022 13:56:13 +0100 Subject: [PATCH 03/50] v3.42.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8293d4198db..82f4116272c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.42.3", + "version": "3.42.4", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { From 1da1460baf68d3b50b7b202741a52a71a69d123f Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Apr 2022 14:54:05 +0100 Subject: [PATCH 04/50] Upgrade matrix-js-sdk to 17.1.0-rc.1 --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 987c12eb06d..e911e1c4955 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#daad3faed54f0b1f1e026a7498b4653e4d01cd90", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "17.1.0-rc.1", "matrix-widget-api": "^0.1.0-beta.18", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index d5f058af71f..7e021ae9857 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6651,9 +6651,10 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "17.0.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b58d09aa9a7a3578c43185becb41ab0b17ce0f98" +matrix-js-sdk@17.1.0-rc.1: + version "17.1.0-rc.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-17.1.0-rc.1.tgz#e96582e6c35d7c1e77c54b397fa6748fbdabe031" + integrity sha512-iqMS+Sxj6Y+cGS5oV7hnoQp0v58O+4HVjGkxfrJ0mskCJ8xsNQU3M4V8NA8e7atXHzhgqcfbB8RkcHe+WGE1fw== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From ff9a563338f2221d57afce2335e5aa0d5a2227aa Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Apr 2022 14:55:24 +0100 Subject: [PATCH 05/50] Prepare changelog for v3.43.0-rc.1 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0126f1f369f..5a5a1713048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +Changes in [3.43.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.43.0-rc.1) (2022-04-19) +=============================================================================================================== + +## ✨ Features + * Improve performance of switching to rooms with lots of servers and ACLs ([\#8347](https://github.com/matrix-org/matrix-react-sdk/pull/8347)). + * Avoid a reflow when setting caret position on an empty composer ([\#8348](https://github.com/matrix-org/matrix-react-sdk/pull/8348)). + * Add message right-click context menu as a labs feature ([\#5672](https://github.com/matrix-org/matrix-react-sdk/pull/5672)). Contributed by @SimonBrandner. + * Live location sharing - basic maximised beacon map ([\#8310](https://github.com/matrix-org/matrix-react-sdk/pull/8310)). + * Live location sharing - render users own beacons in timeline ([\#8296](https://github.com/matrix-org/matrix-react-sdk/pull/8296)). + * Improve Threads beta around degraded mode ([\#8318](https://github.com/matrix-org/matrix-react-sdk/pull/8318)). + * Live location sharing - beacon in timeline happy path ([\#8285](https://github.com/matrix-org/matrix-react-sdk/pull/8285)). + * Add copy button to View Source screen ([\#8278](https://github.com/matrix-org/matrix-react-sdk/pull/8278)). Fixes vector-im/element-web#21482. Contributed by @olivialivia. + * Add heart effect ([\#6188](https://github.com/matrix-org/matrix-react-sdk/pull/6188)). Contributed by @CicadaCinema. + * Update new room icon ([\#8239](https://github.com/matrix-org/matrix-react-sdk/pull/8239)). + +## 🐛 Bug Fixes + * Fix: "Code formatting button does not escape backticks" ([\#8181](https://github.com/matrix-org/matrix-react-sdk/pull/8181)). Contributed by @yaya-usman. + * Fix beta indicator dot causing excessive CPU usage ([\#8340](https://github.com/matrix-org/matrix-react-sdk/pull/8340)). Fixes vector-im/element-web#21793. + * Fix overlapping timestamps on empty messages ([\#8205](https://github.com/matrix-org/matrix-react-sdk/pull/8205)). Fixes vector-im/element-web#21381. Contributed by @goelesha. + * Fix power selector not showing up in user info when state_default undefined ([\#8297](https://github.com/matrix-org/matrix-react-sdk/pull/8297)). Fixes vector-im/element-web#21669. + * Avoid looking up settings during timeline rendering ([\#8313](https://github.com/matrix-org/matrix-react-sdk/pull/8313)). Fixes vector-im/element-web#21740. + * Fix a soft crash with video rooms ([\#8333](https://github.com/matrix-org/matrix-react-sdk/pull/8333)). + * Fixes call tiles overflow ([\#8096](https://github.com/matrix-org/matrix-react-sdk/pull/8096)). Fixes vector-im/element-web#20254. Contributed by @luixxiul. + * Fix a bug with emoji autocomplete sorting where adding the final ":" would cause the emoji with the typed shortcode to no longer be at the top of the autocomplete list. ([\#8086](https://github.com/matrix-org/matrix-react-sdk/pull/8086)). Fixes vector-im/element-web#19302. Contributed by @commonlawfeature. + * Fix image preview sizing for edge cases ([\#8322](https://github.com/matrix-org/matrix-react-sdk/pull/8322)). Fixes vector-im/element-web#20088. + * Refactor SecurityRoomSettingsTab and remove unused state ([\#8306](https://github.com/matrix-org/matrix-react-sdk/pull/8306)). Fixes matrix-org/element-web-rageshakes#12002. + * Don't show the prompt to enable desktop notifications immediately after registration ([\#8274](https://github.com/matrix-org/matrix-react-sdk/pull/8274)). + * Stop tracking threads if threads support is disabled ([\#8308](https://github.com/matrix-org/matrix-react-sdk/pull/8308)). Fixes vector-im/element-web#21766. + * Fix some issues with threads rendering ([\#8305](https://github.com/matrix-org/matrix-react-sdk/pull/8305)). Fixes vector-im/element-web#21670. + * Fix threads rendering issue in Safari ([\#8298](https://github.com/matrix-org/matrix-react-sdk/pull/8298)). Fixes vector-im/element-web#21757. + * Fix space panel width change on hovering over space item ([\#8299](https://github.com/matrix-org/matrix-react-sdk/pull/8299)). Fixes vector-im/element-web#19891. + * Hide the reply in thread button in deployments where beta is forcibly disabled ([\#8294](https://github.com/matrix-org/matrix-react-sdk/pull/8294)). Fixes vector-im/element-web#21753. + * Prevent soft crash around room list header context menu when space changes ([\#8289](https://github.com/matrix-org/matrix-react-sdk/pull/8289)). Fixes matrix-org/element-web-rageshakes#11416, matrix-org/element-web-rageshakes#11692, matrix-org/element-web-rageshakes#11739, matrix-org/element-web-rageshakes#11772, matrix-org/element-web-rageshakes#11891 matrix-org/element-web-rageshakes#11858 and matrix-org/element-web-rageshakes#11456. + * When selecting reply in thread on a thread response open existing thread ([\#8291](https://github.com/matrix-org/matrix-react-sdk/pull/8291)). Fixes vector-im/element-web#21743. + * Handle thread bundled relationships coming from the server via MSC3666 ([\#8292](https://github.com/matrix-org/matrix-react-sdk/pull/8292)). Fixes vector-im/element-web#21450. + * Fix: Avatar preview does not update when same file is selected repeatedly ([\#8288](https://github.com/matrix-org/matrix-react-sdk/pull/8288)). Fixes vector-im/element-web#20098. + * Fix a bug where user gets a warning when changing powerlevel from **Admin** to **custom level (100)** ([\#8248](https://github.com/matrix-org/matrix-react-sdk/pull/8248)). Fixes vector-im/element-web#21682. Contributed by @Jumeb. + * Use a consistent alignment for all text items in a list ([\#8276](https://github.com/matrix-org/matrix-react-sdk/pull/8276)). Fixes vector-im/element-web#21731. Contributed by @luixxiul. + * Fixes button labels being collapsed per a character in CJK languages ([\#8212](https://github.com/matrix-org/matrix-react-sdk/pull/8212)). Fixes vector-im/element-web#21287. Contributed by @luixxiul. + * Fix: Remove jittery timeline scrolling after jumping to an event ([\#8263](https://github.com/matrix-org/matrix-react-sdk/pull/8263)). + * Fix regression of edits showing up in the timeline with hidden events shown ([\#8260](https://github.com/matrix-org/matrix-react-sdk/pull/8260)). Fixes vector-im/element-web#21694. + * Fix reporting events not working ([\#8257](https://github.com/matrix-org/matrix-react-sdk/pull/8257)). Fixes vector-im/element-web#21713. + * Make Jitsi widgets in video rooms immutable ([\#8244](https://github.com/matrix-org/matrix-react-sdk/pull/8244)). Fixes vector-im/element-web#21647. + * Fix: Ensure links to events scroll the correct events into view ([\#8250](https://github.com/matrix-org/matrix-react-sdk/pull/8250)). Fixes vector-im/element-web#19934. + Changes in [3.42.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.3) (2022-04-12) ===================================================================================================== From db89816db909cfb125dcea8aae36f526ebb6b61f Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Apr 2022 14:55:24 +0100 Subject: [PATCH 06/50] v3.43.0-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e911e1c4955..b80d9d9975d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.42.3", + "version": "3.43.0-rc.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -22,7 +22,7 @@ "README.md", "package.json" ], - "main": "./src/index.ts", + "main": "./lib/index.ts", "matrix_src_main": "./src/index.ts", "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", @@ -236,5 +236,6 @@ "text", "json" ] - } + }, + "typings": "./lib/index.d.ts" } From a3a7c60dd7731bb4a253c918984ca7fa0a9ddb7a Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 22 Apr 2022 13:38:27 +0200 Subject: [PATCH 07/50] LLS: Remove beacon info illegal replace relation (#8390) * dont apply illegal replace relation to beacon_info event Signed-off-by: Kerry Archibald * only display tiles for beacon infos with live prop Signed-off-by: Kerry Archibald * copyrights Signed-off-by: Kerry Archibald --- src/events/EventTileFactory.tsx | 6 +++-- src/stores/OwnBeaconStore.ts | 11 +------- src/utils/beacon/timeline.ts | 27 ++++++++++++++++++++ test/stores/OwnBeaconStore-test.ts | 25 ------------------- test/utils/beacon/timeline-test.ts | 40 ++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 37 deletions(-) create mode 100644 src/utils/beacon/timeline.ts create mode 100644 test/utils/beacon/timeline-test.ts diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index ecda002f591..f122329ee5a 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -19,7 +19,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType, MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; import { M_POLL_START, Optional } from "matrix-events-sdk"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import EditorStateTransfer from "../utils/EditorStateTransfer"; import { RoomPermalinkCreator } from "../utils/permalinks/Permalinks"; @@ -44,6 +43,7 @@ import { getMessageModerationState, MessageModerationState } from "../utils/Even import HiddenBody from "../components/views/messages/HiddenBody"; import SettingsStore from "../settings/SettingsStore"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; +import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps { @@ -219,7 +219,9 @@ export function pickFactory( // Try and pick a state event factory, if we can. if (mxEvent.isState()) { if ( - M_BEACON_INFO.matches(evType) && + shouldDisplayAsBeaconTile(mxEvent) && + // settings store access here temporarily during labs + // only hit when a beacon_info event is hit SettingsStore.getValue("feature_location_share_live") ) { return MessageEventFactory; diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index eabe9ea083b..1bef120ae70 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -20,7 +20,6 @@ import { BeaconIdentifier, BeaconEvent, MatrixEvent, - RelationType, Room, RoomMember, RoomState, @@ -449,20 +448,12 @@ export class OwnBeaconStore extends AsyncStoreWithClient { ...update, }; - const newContent = makeBeaconInfoContent(timeout, + const updateContent = makeBeaconInfoContent(timeout, live, description, assetType, timestamp, ); - const updateContent = { - ...newContent, - "m.new_content": newContent, - "m.relates_to": { - "rel_type": RelationType.Replace, - "event_id": beacon.beaconInfoId, - }, - }; await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, updateContent); }; diff --git a/src/utils/beacon/timeline.ts b/src/utils/beacon/timeline.ts new file mode 100644 index 00000000000..e6e8f2311d5 --- /dev/null +++ b/src/utils/beacon/timeline.ts @@ -0,0 +1,27 @@ +/* +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 { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; + +/** + * beacon_info events without live property set to true + * should be displayed in the timeline + */ +export const shouldDisplayAsBeaconTile = (event: MatrixEvent): boolean => ( + M_BEACON_INFO.matches(event.getType()) && + !!event.getContent()?.live +); diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index b5713f6348f..6a95c82ad07 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -20,7 +20,6 @@ import { BeaconEvent, getBeaconInfoIdentifier, MatrixEvent, - RelationType, RoomStateEvent, RoomMember, } from "matrix-js-sdk/src/matrix"; @@ -473,14 +472,6 @@ describe('OwnBeaconStore', () => { const expectedUpdateContent = { ...prevEventContent, live: false, - ["m.new_content"]: { - ...prevEventContent, - live: false, - }, - ["m.relates_to"]: { - event_id: alicesRoom1BeaconInfo.getId(), - rel_type: RelationType.Replace, - }, }; expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledWith( room1Id, @@ -650,14 +641,6 @@ describe('OwnBeaconStore', () => { const expectedUpdateContent = { ...prevEventContent, live: false, - ["m.new_content"]: { - ...prevEventContent, - live: false, - }, - ["m.relates_to"]: { - event_id: alicesRoom1BeaconInfo.getId(), - rel_type: RelationType.Replace, - }, }; expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledWith( room1Id, @@ -683,14 +666,6 @@ describe('OwnBeaconStore', () => { const expectedUpdateContent = { ...prevEventContent, live: false, - ["m.new_content"]: { - ...prevEventContent, - live: false, - }, - ["m.relates_to"]: { - event_id: alicesRoom1BeaconInfo.getId(), - rel_type: RelationType.Replace, - }, }; expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledWith( room1Id, diff --git a/test/utils/beacon/timeline-test.ts b/test/utils/beacon/timeline-test.ts new file mode 100644 index 00000000000..96be565de74 --- /dev/null +++ b/test/utils/beacon/timeline-test.ts @@ -0,0 +1,40 @@ +/* +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 { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { shouldDisplayAsBeaconTile } from "../../../src/utils/beacon/timeline"; +import { makeBeaconInfoEvent } from "../../test-utils"; + +describe('shouldDisplayAsBeaconTile', () => { + const userId = '@user:server'; + const roomId = '!room:server'; + const liveBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: true }); + const notLiveBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: false }); + const memberEvent = new MatrixEvent({ type: EventType.RoomMember }); + + it('returns true for a beacon with live property set to true', () => { + expect(shouldDisplayAsBeaconTile(liveBeacon)).toBe(true); + }); + + it('returns false for a beacon with live property set to false', () => { + expect(shouldDisplayAsBeaconTile(notLiveBeacon)).toBe(false); + }); + + it('returns false for a non beacon event', () => { + expect(shouldDisplayAsBeaconTile(memberEvent)).toBe(false); + }); +}); From 988d3002584e66fe60ae783410efe8fc7026db03 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 22 Apr 2022 14:05:36 +0200 Subject: [PATCH 08/50] Live location sharing: only share to beacons created on device (#8378) * create live beacons in ownbeaconstore and test Signed-off-by: Kerry Archibald * more mocks in RoomLiveShareWarning Signed-off-by: Kerry Archibald * extend mocks in components Signed-off-by: Kerry Archibald * comment Signed-off-by: Kerry Archibald * remove another comment Signed-off-by: Kerry Archibald * extra ? hedge in roommembers change Signed-off-by: Kerry Archibald * listen to destroy and prune local store on stop Signed-off-by: Kerry Archibald * tests Signed-off-by: Kerry Archibald * update copy pasted copyright to 2022 Signed-off-by: Kerry Archibald --- .../views/location/shareLocation.ts | 3 +- src/stores/OwnBeaconStore.ts | 78 +++++- src/utils/beacon/timeline.ts | 2 +- .../beacon/RoomLiveShareWarning-test.tsx | 11 + .../views/location/LocationShareMenu-test.tsx | 31 ++- test/stores/OwnBeaconStore-test.ts | 234 +++++++++++++++++- test/utils/beacon/timeline-test.ts | 2 +- 7 files changed, 341 insertions(+), 20 deletions(-) diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index 6654a389a06..895f2c23c6c 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -25,6 +25,7 @@ import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import SdkConfig from "../../../SdkConfig"; +import { OwnBeaconStore } from "../../../stores/OwnBeaconStore"; export enum LocationShareType { Own = 'Own', @@ -70,7 +71,7 @@ export const shareLiveLocation = ( ): ShareLocationFn => async ({ timeout }) => { const description = _t(`%(displayName)s's live location`, { displayName }); try { - await client.unstable_createLiveBeacon( + await OwnBeaconStore.instance.createLiveBeacon( roomId, makeBeaconInfoContent( timeout ?? DEFAULT_LIVE_DURATION, diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 1bef120ae70..b4b4a4328a7 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -28,7 +28,7 @@ import { import { BeaconInfoState, makeBeaconContent, makeBeaconInfoContent, } from "matrix-js-sdk/src/content-helpers"; -import { M_BEACON } from "matrix-js-sdk/src/@types/beacon"; +import { MBeaconInfoEventContent, M_BEACON } from "matrix-js-sdk/src/@types/beacon"; import { logger } from "matrix-js-sdk/src/logger"; import defaultDispatcher from "../dispatcher/dispatcher"; @@ -64,6 +64,30 @@ type OwnBeaconStoreState = { beaconsByRoomId: Map>; liveBeaconIds: BeaconIdentifier[]; }; + +const CREATED_BEACONS_KEY = 'mx_live_beacon_created_id'; +const removeLocallyCreateBeaconEventId = (eventId: string): void => { + const ids = getLocallyCreatedBeaconEventIds(); + window.localStorage.setItem(CREATED_BEACONS_KEY, JSON.stringify(ids.filter(id => id !== eventId))); +}; +const storeLocallyCreateBeaconEventId = (eventId: string): void => { + const ids = getLocallyCreatedBeaconEventIds(); + window.localStorage.setItem(CREATED_BEACONS_KEY, JSON.stringify([...ids, eventId])); +}; + +const getLocallyCreatedBeaconEventIds = (): string[] => { + let ids: string[]; + try { + ids = JSON.parse(window.localStorage.getItem(CREATED_BEACONS_KEY) ?? '[]'); + if (!Array.isArray(ids)) { + throw new Error('Invalid stored value'); + } + } catch (error) { + logger.error('Failed to retrieve locally created beacon event ids', error); + ids = []; + } + return ids; +}; export class OwnBeaconStore extends AsyncStoreWithClient { private static internalInstance = new OwnBeaconStore(); // users beacons, keyed by event type @@ -110,6 +134,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon); this.matrixClient.removeListener(BeaconEvent.Update, this.onUpdateBeacon); + this.matrixClient.removeListener(BeaconEvent.Destroy, this.onDestroyBeacon); this.matrixClient.removeListener(RoomStateEvent.Members, this.onRoomStateMembers); this.beacons.forEach(beacon => beacon.destroy()); @@ -125,6 +150,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.on(BeaconEvent.New, this.onNewBeacon); this.matrixClient.on(BeaconEvent.Update, this.onUpdateBeacon); + this.matrixClient.on(BeaconEvent.Destroy, this.onDestroyBeacon); this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers); this.initialiseBeaconState(); @@ -188,7 +214,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient { return; } - return await this.updateBeaconEvent(beacon, { live: false }); + await this.updateBeaconEvent(beacon, { live: false }); + + // prune from local store + removeLocallyCreateBeaconEventId(beacon.beaconInfoId); }; /** @@ -215,6 +244,15 @@ export class OwnBeaconStore extends AsyncStoreWithClient { beacon.monitorLiveness(); }; + private onDestroyBeacon = (beaconIdentifier: BeaconIdentifier): void => { + // check if we care about this beacon + if (!this.beacons.has(beaconIdentifier)) { + return; + } + + this.checkLiveness(); + }; + private onBeaconLiveness = (isLive: boolean, beacon: Beacon): void => { // check if we care about this beacon if (!this.beacons.has(beacon.identifier)) { @@ -249,7 +287,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // stop watching beacons in rooms where user is no longer a member if (member.membership === 'leave' || member.membership === 'ban') { - this.beaconsByRoomId.get(roomState.roomId).forEach(this.removeBeacon); + this.beaconsByRoomId.get(roomState.roomId)?.forEach(this.removeBeacon); this.beaconsByRoomId.delete(roomState.roomId); } }; @@ -308,9 +346,14 @@ export class OwnBeaconStore extends AsyncStoreWithClient { }; private checkLiveness = (): void => { + const locallyCreatedBeaconEventIds = getLocallyCreatedBeaconEventIds(); const prevLiveBeaconIds = this.getLiveBeaconIds(); this.liveBeaconIds = [...this.beacons.values()] - .filter(beacon => beacon.isLive) + .filter(beacon => + beacon.isLive && + // only beacons created on this device should be shared to + locallyCreatedBeaconEventIds.includes(beacon.beaconInfoId), + ) .sort(sortBeaconsByLatestCreation) .map(beacon => beacon.identifier); @@ -339,6 +382,32 @@ export class OwnBeaconStore extends AsyncStoreWithClient { } }; + public createLiveBeacon = async ( + roomId: Room['roomId'], + beaconInfoContent: MBeaconInfoEventContent, + ): Promise => { + // eslint-disable-next-line camelcase + const { event_id } = await this.matrixClient.unstable_createLiveBeacon( + roomId, + beaconInfoContent, + ); + + storeLocallyCreateBeaconEventId(event_id); + + // try to stop any other live beacons + // in this room + this.beaconsByRoomId.get(roomId)?.forEach(beaconId => { + if (this.getBeaconById(beaconId)?.isLive) { + try { + // don't await, this is best effort + this.stopBeacon(beaconId); + } catch (error) { + logger.error('Failed to stop live beacons', error); + } + } + }); + }; + /** * Geolocation */ @@ -420,7 +489,6 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.stopPollingLocation(); // kill live beacons when location permissions are revoked - // TODO may need adjustment when PSF-797 is done await Promise.all(this.liveBeaconIds.map(this.stopBeacon)); }; diff --git a/src/utils/beacon/timeline.ts b/src/utils/beacon/timeline.ts index e6e8f2311d5..d00872f8651 100644 --- a/src/utils/beacon/timeline.ts +++ b/src/utils/beacon/timeline.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2022 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. diff --git a/test/components/views/beacon/RoomLiveShareWarning-test.tsx b/test/components/views/beacon/RoomLiveShareWarning-test.tsx index 6fa6098f2c7..030ac8ea4af 100644 --- a/test/components/views/beacon/RoomLiveShareWarning-test.tsx +++ b/test/components/views/beacon/RoomLiveShareWarning-test.tsx @@ -93,10 +93,20 @@ describe('', () => { return component; }; + const localStorageSpy = jest.spyOn(localStorage.__proto__, 'getItem').mockReturnValue(undefined); + beforeEach(() => { mockGeolocation(); jest.spyOn(global.Date, 'now').mockReturnValue(now); mockClient.unstable_setLiveBeacon.mockReset().mockResolvedValue({ event_id: '1' }); + + // assume all beacons were created on this device + localStorageSpy.mockReturnValue(JSON.stringify([ + room1Beacon1.getId(), + room2Beacon1.getId(), + room2Beacon2.getId(), + room3Beacon1.getId(), + ])); }); afterEach(async () => { @@ -106,6 +116,7 @@ describe('', () => { afterAll(() => { jest.spyOn(global.Date, 'now').mockRestore(); + localStorageSpy.mockRestore(); }); const getExpiryText = wrapper => findByTestId(wrapper, 'room-live-share-expiry').text(); diff --git a/test/components/views/location/LocationShareMenu-test.tsx b/test/components/views/location/LocationShareMenu-test.tsx index e873b9db89d..ce52125f86f 100644 --- a/test/components/views/location/LocationShareMenu-test.tsx +++ b/test/components/views/location/LocationShareMenu-test.tsx @@ -29,9 +29,15 @@ import { ChevronFace } from '../../../../src/components/structures/ContextMenu'; import SettingsStore from '../../../../src/settings/SettingsStore'; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import { LocationShareType } from '../../../../src/components/views/location/shareLocation'; -import { findByTagAndTestId, flushPromises } from '../../../test-utils'; +import { + findByTagAndTestId, + flushPromises, + getMockClientWithEventEmitter, + setupAsyncStoreWithClient, +} from '../../../test-utils'; import Modal from '../../../../src/Modal'; import { DEFAULT_DURATION_MS } from '../../../../src/components/views/location/LiveDurationDropdown'; +import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore'; jest.mock('../../../../src/utils/location/findMapStyleUrl', () => ({ findMapStyleUrl: jest.fn().mockReturnValue('test'), @@ -57,17 +63,15 @@ jest.mock('../../../../src/Modal', () => ({ describe('', () => { const userId = '@ernie:server.org'; - const mockClient = { - on: jest.fn(), - off: jest.fn(), - removeListener: jest.fn(), + const mockClient = getMockClientWithEventEmitter({ getUserId: jest.fn().mockReturnValue(userId), getClientWellKnown: jest.fn().mockResolvedValue({ map_style_url: 'maps.com', }), sendMessage: jest.fn(), - unstable_createLiveBeacon: jest.fn().mockResolvedValue({}), - }; + unstable_createLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }), + getVisibleRooms: jest.fn().mockReturnValue([]), + }); const defaultProps = { menuPosition: { @@ -90,19 +94,28 @@ describe('', () => { type: 'geolocate', }; + const makeOwnBeaconStore = async () => { + const store = OwnBeaconStore.instance; + + await setupAsyncStoreWithClient(store, mockClient); + return store; + }; + const getComponent = (props = {}) => mount(, { wrappingComponent: MatrixClientContext.Provider, wrappingComponentProps: { value: mockClient }, }); - beforeEach(() => { + beforeEach(async () => { jest.spyOn(logger, 'error').mockRestore(); mocked(SettingsStore).getValue.mockReturnValue(false); mockClient.sendMessage.mockClear(); - mockClient.unstable_createLiveBeacon.mockClear().mockResolvedValue(undefined); + mockClient.unstable_createLiveBeacon.mockClear().mockResolvedValue({ event_id: '1' }); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient); mocked(Modal).createTrackedDialog.mockClear(); + + await makeOwnBeaconStore(); }); const getShareTypeOption = (component: ReactWrapper, shareType: LocationShareType) => diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 6a95c82ad07..df1d868e40f 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -23,7 +23,7 @@ import { RoomStateEvent, RoomMember, } from "matrix-js-sdk/src/matrix"; -import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers"; +import { makeBeaconContent, makeBeaconInfoContent } from "matrix-js-sdk/src/content-helpers"; import { M_BEACON } from "matrix-js-sdk/src/@types/beacon"; import { logger } from "matrix-js-sdk/src/logger"; @@ -64,6 +64,7 @@ describe('OwnBeaconStore', () => { getVisibleRooms: jest.fn().mockReturnValue([]), unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }), sendEvent: jest.fn().mockResolvedValue({ event_id: '1' }), + unstable_createLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }), }); const room1Id = '$room1:server.org'; const room2Id = '$room2:server.org'; @@ -144,6 +145,7 @@ describe('OwnBeaconStore', () => { beaconInfoEvent.getSender(), beaconInfoEvent.getRoomId(), { isLive, timeout: beacon.beaconInfo.timeout }, + 'update-event-id', ); beacon.update(updateEvent); @@ -156,6 +158,9 @@ describe('OwnBeaconStore', () => { mockClient.emit(BeaconEvent.New, beaconInfoEvent, beacon); }; + const localStorageGetSpy = jest.spyOn(localStorage.__proto__, 'getItem').mockReturnValue(undefined); + const localStorageSetSpy = jest.spyOn(localStorage.__proto__, 'setItem').mockImplementation(() => {}); + beforeEach(() => { geolocation = mockGeolocation(); mockClient.getVisibleRooms.mockReturnValue([]); @@ -164,6 +169,9 @@ describe('OwnBeaconStore', () => { jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore(); jest.spyOn(logger, 'error').mockRestore(); + + localStorageGetSpy.mockClear().mockReturnValue(undefined); + localStorageSetSpy.mockClear(); }); afterEach(async () => { @@ -172,6 +180,10 @@ describe('OwnBeaconStore', () => { jest.clearAllTimers(); }); + afterAll(() => { + localStorageGetSpy.mockRestore(); + }); + describe('onReady()', () => { it('initialises correctly with no beacons', async () => { makeRoomsWithStateEvents(); @@ -195,7 +207,27 @@ describe('OwnBeaconStore', () => { bobsOldRoom1BeaconInfo, ]); const store = await makeOwnBeaconStore(); - expect(store.hasLiveBeacons()).toBe(true); + expect(store.beaconsByRoomId.get(room1Id)).toEqual(new Set([ + getBeaconInfoIdentifier(alicesRoom1BeaconInfo), + ])); + expect(store.beaconsByRoomId.get(room2Id)).toEqual(new Set([ + getBeaconInfoIdentifier(alicesRoom2BeaconInfo), + ])); + }); + + it('updates live beacon ids when users own beacons were created on device', async () => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ])); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons(room1Id)).toBeTruthy(); expect(store.getLiveBeaconIds()).toEqual([ getBeaconInfoIdentifier(alicesRoom1BeaconInfo), getBeaconInfoIdentifier(alicesRoom2BeaconInfo), @@ -214,6 +246,10 @@ describe('OwnBeaconStore', () => { }); it('does geolocation and sends location immediatley when user has live beacons', async () => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ])); makeRoomsWithStateEvents([ alicesRoom1BeaconInfo, alicesRoom2BeaconInfo, @@ -245,7 +281,8 @@ describe('OwnBeaconStore', () => { expect(removeSpy.mock.calls[0]).toEqual(expect.arrayContaining([BeaconEvent.LivenessChange])); expect(removeSpy.mock.calls[1]).toEqual(expect.arrayContaining([BeaconEvent.New])); expect(removeSpy.mock.calls[2]).toEqual(expect.arrayContaining([BeaconEvent.Update])); - expect(removeSpy.mock.calls[3]).toEqual(expect.arrayContaining([RoomStateEvent.Members])); + expect(removeSpy.mock.calls[3]).toEqual(expect.arrayContaining([BeaconEvent.Destroy])); + expect(removeSpy.mock.calls[4]).toEqual(expect.arrayContaining([RoomStateEvent.Members])); }); it('destroys beacons', async () => { @@ -270,6 +307,10 @@ describe('OwnBeaconStore', () => { bobsRoom1BeaconInfo, bobsOldRoom1BeaconInfo, ]); + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ])); }); it('returns true when user has live beacons', async () => { @@ -320,6 +361,10 @@ describe('OwnBeaconStore', () => { bobsRoom1BeaconInfo, bobsOldRoom1BeaconInfo, ]); + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ])); }); it('returns live beacons when user has live beacons', async () => { @@ -371,6 +416,13 @@ describe('OwnBeaconStore', () => { }); describe('on new beacon event', () => { + // assume all beacons were created on this device + beforeEach(() => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ])); + }); it('ignores events for irrelevant beacons', async () => { makeRoomsWithStateEvents([]); const store = await makeOwnBeaconStore(); @@ -425,6 +477,16 @@ describe('OwnBeaconStore', () => { }); describe('on liveness change event', () => { + // assume all beacons were created on this device + beforeEach(() => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + alicesOldRoomIdBeaconInfo.getId(), + 'update-event-id', + ])); + }); + it('ignores events for irrelevant beacons', async () => { makeRoomsWithStateEvents([ alicesRoom1BeaconInfo, @@ -501,6 +563,13 @@ describe('OwnBeaconStore', () => { }); describe('on room membership changes', () => { + // assume all beacons were created on this device + beforeEach(() => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ])); + }); it('ignores events for rooms without beacons', async () => { const membershipEvent = makeMembershipEvent(room2Id, aliceId); // no beacons for room2 @@ -606,6 +675,54 @@ describe('OwnBeaconStore', () => { }); }); + describe('on destroy event', () => { + // assume all beacons were created on this device + beforeEach(() => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + alicesOldRoomIdBeaconInfo.getId(), + 'update-event-id', + ])); + }); + + it('ignores events for irrelevant beacons', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + const bobsLiveBeacon = new Beacon(bobsRoom1BeaconInfo); + + mockClient.emit(BeaconEvent.Destroy, bobsLiveBeacon.identifier); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('updates state and emits beacon liveness changes from true to false', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + + // live before + expect(store.hasLiveBeacons()).toBe(true); + const emitSpy = jest.spyOn(store, 'emit'); + + const beacon = store.getBeaconById(getBeaconInfoIdentifier(alicesRoom1BeaconInfo)); + + beacon.destroy(); + mockClient.emit(BeaconEvent.Destroy, beacon.identifier); + + expect(store.hasLiveBeacons()).toBe(false); + expect(store.hasLiveBeacons(room1Id)).toBe(false); + expect(emitSpy).toHaveBeenCalledWith(OwnBeaconStoreEvent.LivenessChange, []); + }); + }); + describe('stopBeacon()', () => { beforeEach(() => { makeRoomsWithStateEvents([ @@ -672,9 +789,38 @@ describe('OwnBeaconStore', () => { expectedUpdateContent, ); }); + + it('removes beacon event id from local store', async () => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ])); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + + await store.stopBeacon(getBeaconInfoIdentifier(alicesRoom1BeaconInfo)); + + expect(localStorageSetSpy).toHaveBeenCalledWith( + 'mx_live_beacon_created_id', + // stopped beacon's event_id was removed + JSON.stringify([alicesRoom2BeaconInfo.getId()]), + ); + }); }); describe('publishing positions', () => { + // assume all beacons were created on this device + beforeEach(() => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + alicesOldRoomIdBeaconInfo.getId(), + 'update-event-id', + ])); + }); + it('stops watching position when user has no more live beacons', async () => { // geolocation is only going to emit 1 position geolocation.watchPosition.mockImplementation( @@ -842,6 +988,7 @@ describe('OwnBeaconStore', () => { // called for each position from watchPosition expect(mockClient.sendEvent).toHaveBeenCalledTimes(5); expect(store.beaconHasWireError(getBeaconInfoIdentifier(alicesRoom1BeaconInfo))).toBe(false); + expect(store.getLiveBeaconIdsWithWireError()).toEqual([]); expect(store.hasWireErrors()).toBe(false); }); @@ -892,6 +1039,12 @@ describe('OwnBeaconStore', () => { // only two allowed failures expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); expect(store.beaconHasWireError(getBeaconInfoIdentifier(alicesRoom1BeaconInfo))).toBe(true); + expect(store.getLiveBeaconIdsWithWireError()).toEqual( + [getBeaconInfoIdentifier(alicesRoom1BeaconInfo)], + ); + expect(store.getLiveBeaconIdsWithWireError(room1Id)).toEqual( + [getBeaconInfoIdentifier(alicesRoom1BeaconInfo)], + ); expect(store.hasWireErrors()).toBe(true); expect(emitSpy).toHaveBeenCalledWith( OwnBeaconStoreEvent.WireError, getBeaconInfoIdentifier(alicesRoom1BeaconInfo), @@ -1055,4 +1208,79 @@ describe('OwnBeaconStore', () => { expect(mockClient.sendEvent).not.toHaveBeenCalled(); }); }); + + describe('createLiveBeacon', () => { + const newEventId = 'new-beacon-event-id'; + const loggerErrorSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}); + beforeEach(() => { + localStorageGetSpy.mockReturnValue(JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + ])); + + localStorageSetSpy.mockClear(); + + mockClient.unstable_createLiveBeacon.mockResolvedValue({ event_id: newEventId }); + }); + + it('creates a live beacon', async () => { + const store = await makeOwnBeaconStore(); + const content = makeBeaconInfoContent(100); + await store.createLiveBeacon(room1Id, content); + expect(mockClient.unstable_createLiveBeacon).toHaveBeenCalledWith(room1Id, content); + }); + + it('sets new beacon event id in local storage', async () => { + const store = await makeOwnBeaconStore(); + const content = makeBeaconInfoContent(100); + await store.createLiveBeacon(room1Id, content); + + expect(localStorageSetSpy).toHaveBeenCalledWith( + 'mx_live_beacon_created_id', + JSON.stringify([ + alicesRoom1BeaconInfo.getId(), + newEventId, + ]), + ); + }); + + it('handles saving beacon event id when local storage has bad value', async () => { + localStorageGetSpy.mockReturnValue(JSON.stringify({ id: '1' })); + const store = await makeOwnBeaconStore(); + const content = makeBeaconInfoContent(100); + await store.createLiveBeacon(room1Id, content); + + // stored successfully + expect(localStorageSetSpy).toHaveBeenCalledWith( + 'mx_live_beacon_created_id', + JSON.stringify([ + newEventId, + ]), + ); + }); + + it('creates a live beacon without error when no beacons exist for room', async () => { + const store = await makeOwnBeaconStore(); + const content = makeBeaconInfoContent(100); + await store.createLiveBeacon(room1Id, content); + + // didn't throw, no error log + expect(loggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('stops live beacons for room after creating new beacon', async () => { + // room1 already has a beacon + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + // but it was not created on this device + localStorageGetSpy.mockReturnValue(undefined); + + const store = await makeOwnBeaconStore(); + const content = makeBeaconInfoContent(100); + await store.createLiveBeacon(room1Id, content); + + // update beacon called + expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalled(); + }); + }); }); diff --git a/test/utils/beacon/timeline-test.ts b/test/utils/beacon/timeline-test.ts index 96be565de74..59217d24590 100644 --- a/test/utils/beacon/timeline-test.ts +++ b/test/utils/beacon/timeline-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2022 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. From 2a396a406d7e13a534bf34350e4d590c68222816 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 22 Apr 2022 08:17:31 -0400 Subject: [PATCH 09/50] Stick connected video rooms to the top of the room list (#8353) --- src/stores/room-list/RoomListStore.ts | 5 +- src/stores/room-list/algorithms/Algorithm.ts | 89 +++++++++++++++++-- .../room-list/algorithms/Algorithm-test.ts | 64 +++++++++++++ 3 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 test/stores/room-list/algorithms/Algorithm-test.ts diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 125b045c9a4..dc7f405c62d 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -70,6 +70,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { constructor() { super(defaultDispatcher); this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares + this.algorithm.start(); } private setupWatchers() { @@ -96,6 +97,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated); + this.algorithm.stop(); this.algorithm = new Algorithm(); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated); @@ -479,8 +481,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } } - private onAlgorithmListUpdated = () => { + private onAlgorithmListUpdated = (forceUpdate: boolean) => { this.updateFn.mark(); + if (forceUpdate) this.updateFn.trigger(); }; private onAlgorithmFilterUpdated = () => { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 55e877ed5d4..edd406fd07b 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -35,6 +35,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import { VisibilityProvider } from "../filters/VisibilityProvider"; +import VideoChannelStore, { VideoChannelEvent } from "../../VideoChannelStore"; /** * Fired when the Algorithm has determined a list has been updated. @@ -84,8 +85,14 @@ export class Algorithm extends EventEmitter { */ public updatesInhibited = false; - public constructor() { - super(); + public start() { + VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoRoom); + VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoRoom); + } + + public stop() { + VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.updateVideoRoom); + VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.updateVideoRoom); } public get stickyRoom(): Room { @@ -108,6 +115,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms = val; this.recalculateFilteredRooms(); this.recalculateStickyRoom(); + this.recalculateVideoRoom(); } protected get cachedRooms(): ITagMap { @@ -145,6 +153,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms[tagId] = algorithm.orderedRooms; this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed + this.recalculateVideoRoom(tagId); } public getListOrdering(tagId: TagID): ListAlgorithm { @@ -164,6 +173,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms[tagId] = algorithm.orderedRooms; this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed + this.recalculateVideoRoom(tagId); } public addFilterCondition(filterCondition: IFilterCondition): void { @@ -311,12 +321,30 @@ export class Algorithm extends EventEmitter { this.recalculateFilteredRoomsForTag(tag); if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateFilteredRoomsForTag(lastStickyRoom.tag); this.recalculateStickyRoom(); + this.recalculateVideoRoom(tag); + if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateVideoRoom(lastStickyRoom.tag); // Finally, trigger an update if (this.updatesInhibited) return; this.emit(LIST_UPDATED_EVENT); } + /** + * Update the stickiness of video rooms. + */ + public updateVideoRoom = () => { + // In case we're unsticking a video room, sort it back into natural order + this.recalculateFilteredRooms(); + this.recalculateStickyRoom(); + + this.recalculateVideoRoom(); + + if (this.updatesInhibited) return; + // This isn't in response to any particular RoomListStore update, + // so notify the store that it needs to force-update + this.emit(LIST_UPDATED_EVENT, true); + }; + protected recalculateFilteredRooms() { if (!this.hasFilters) { return; @@ -374,6 +402,13 @@ export class Algorithm extends EventEmitter { } } + private initCachedStickyRooms() { + this._cachedStickyRooms = {}; + for (const tagId of Object.keys(this.cachedRooms)) { + this._cachedStickyRooms[tagId] = [...this.cachedRooms[tagId]]; // shallow clone + } + } + /** * Recalculate the sticky room position. If this is being called in relation to * a specific tag being updated, it should be given to this function to optimize @@ -400,17 +435,13 @@ export class Algorithm extends EventEmitter { } if (!this._cachedStickyRooms || !updatedTag) { - const stickiedTagMap: ITagMap = {}; - for (const tagId of Object.keys(this.cachedRooms)) { - stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone - } - this._cachedStickyRooms = stickiedTagMap; + this.initCachedStickyRooms(); } if (updatedTag) { // Update the tag indicated by the caller, if possible. This is mostly to ensure // our cache is up to date. - this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone + this._cachedStickyRooms[updatedTag] = [...this.cachedRooms[updatedTag]]; // shallow clone } // Now try to insert the sticky room, if we need to. @@ -426,6 +457,46 @@ export class Algorithm extends EventEmitter { this.emit(LIST_UPDATED_EVENT); } + /** + * Recalculate the position of any video rooms. If this is being called in relation to + * a specific tag being updated, it should be given to this function to optimize + * the call. + * + * This expects to be called *after* the sticky rooms are updated, and sticks the + * currently connected video room to the top of its tag. + * + * @param updatedTag The tag that was updated, if possible. + */ + protected recalculateVideoRoom(updatedTag: TagID = null): void { + if (!updatedTag) { + // Assume all tags need updating + // We're not modifying the map here, so can safely rely on the cached values + // rather than the explicitly sticky map. + for (const tagId of Object.keys(this.cachedRooms)) { + if (!tagId) { + throw new Error("Unexpected recursion: falsy tag"); + } + this.recalculateVideoRoom(tagId); + } + return; + } + + const videoRoomId = VideoChannelStore.instance.connected ? VideoChannelStore.instance.roomId : null; + + if (videoRoomId) { + // We operate directly on the sticky rooms map + if (!this._cachedStickyRooms) this.initCachedStickyRooms(); + const rooms = this._cachedStickyRooms[updatedTag]; + const videoRoomIdxInTag = rooms.findIndex(r => r.roomId === videoRoomId); + if (videoRoomIdxInTag < 0) return; // no-op + + const videoRoom = rooms[videoRoomIdxInTag]; + rooms.splice(videoRoomIdxInTag, 1); + rooms.unshift(videoRoom); + this._cachedStickyRooms[updatedTag] = rooms; // re-set because references aren't always safe + } + } + /** * Asks the Algorithm to regenerate all lists, using the tags given * as reference for which lists to generate and which way to generate @@ -706,6 +777,7 @@ export class Algorithm extends EventEmitter { this._cachedRooms[rmTag] = algorithm.orderedRooms; this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed + this.recalculateVideoRoom(rmTag); } for (const addTag of diff.added) { const algorithm: OrderingAlgorithm = this.algorithms[addTag]; @@ -782,6 +854,7 @@ export class Algorithm extends EventEmitter { // Flag that we've done something this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed + this.recalculateVideoRoom(tag); changed = true; } diff --git a/test/stores/room-list/algorithms/Algorithm-test.ts b/test/stores/room-list/algorithms/Algorithm-test.ts new file mode 100644 index 00000000000..d83f6ccca6c --- /dev/null +++ b/test/stores/room-list/algorithms/Algorithm-test.ts @@ -0,0 +1,64 @@ +/* +Copyright 2022 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 { stubClient, stubVideoChannelStore, mkRoom } from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { DefaultTagID } from "../../../../src/stores/room-list/models"; +import { SortAlgorithm, ListAlgorithm } from "../../../../src/stores/room-list/algorithms/models"; +import "../../../../src/stores/room-list/RoomListStore"; // must be imported before Algorithm to avoid cycles +import { Algorithm } from "../../../../src/stores/room-list/algorithms/Algorithm"; + +describe("Algorithm", () => { + let videoChannelStore; + let algorithm; + let textRoom; + let videoRoom; + beforeEach(() => { + stubClient(); + const cli = MatrixClientPeg.get(); + DMRoomMap.makeShared(); + videoChannelStore = stubVideoChannelStore(); + algorithm = new Algorithm(); + algorithm.start(); + + textRoom = mkRoom(cli, "!text:example.org"); + videoRoom = mkRoom(cli, "!video:example.org"); + videoRoom.isElementVideoRoom.mockReturnValue(true); + algorithm.populateTags( + { [DefaultTagID.Untagged]: SortAlgorithm.Alphabetic }, + { [DefaultTagID.Untagged]: ListAlgorithm.Natural }, + ); + algorithm.setKnownRooms([textRoom, videoRoom]); + }); + + afterEach(() => { + algorithm.stop(); + }); + + it("sticks video rooms to the top when they connect", () => { + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([textRoom, videoRoom]); + videoChannelStore.connect("!video:example.org"); + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([videoRoom, textRoom]); + }); + + it("unsticks video rooms from the top when they disconnect", () => { + videoChannelStore.connect("!video:example.org"); + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([videoRoom, textRoom]); + videoChannelStore.disconnect(); + expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([textRoom, videoRoom]); + }); +}); From 03c46770f445f1f264cb3708f77b889e39237d66 Mon Sep 17 00:00:00 2001 From: Esha Goel <54431564+goelesha@users.noreply.github.com> Date: Fri, 22 Apr 2022 20:14:07 +0530 Subject: [PATCH 10/50] Changed font-weight to 400 to support light weight font (#8345) * changed font-weight to 400 to support light weight font * removed font-weight line and _CreateRoom.scss file * removed _CreateRoom from _components.scss --- res/css/_common.scss | 1 - res/css/_components.scss | 1 - res/css/structures/_CreateRoom.scss | 36 ----------------------------- 3 files changed, 38 deletions(-) delete mode 100644 res/css/structures/_CreateRoom.scss diff --git a/res/css/_common.scss b/res/css/_common.scss index b5d873194dd..6854b485ed9 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -294,7 +294,6 @@ legend { background-color: $background; color: $light-fg-color; z-index: 4012; - font-weight: 300; font-size: $font-15px; position: relative; padding: 24px; diff --git a/res/css/_components.scss b/res/css/_components.scss index f4b833bdd47..bcd3111663e 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -27,7 +27,6 @@ @import "./structures/_BackdropPanel.scss"; @import "./structures/_CompatibilityPage.scss"; @import "./structures/_ContextualMenu.scss"; -@import "./structures/_CreateRoom.scss"; @import "./structures/_FileDropTarget.scss"; @import "./structures/_FilePanel.scss"; @import "./structures/_GenericErrorPage.scss"; diff --git a/res/css/structures/_CreateRoom.scss b/res/css/structures/_CreateRoom.scss deleted file mode 100644 index 78e6881b108..00000000000 --- a/res/css/structures/_CreateRoom.scss +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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_CreateRoom { - width: 960px; - margin-left: auto; - margin-right: auto; - color: $primary-content; -} - -.mx_CreateRoom input, -.mx_CreateRoom textarea { - border-radius: 3px; - border: 1px solid $strong-input-border-color; - font-weight: 300; - font-size: $font-13px; - padding: 9px; - margin-top: 6px; -} - -.mx_CreateRoom_description { - width: 330px; -} From ee2ee3c08c86746346de944f97303c1542e4e7d6 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 22 Apr 2022 17:09:44 +0200 Subject: [PATCH 11/50] Implement new Read Receipt design (#8389) * feat: introduce new alignment types for tooltip * feat: introduce new hook for tooltips * feat: allow using onFocus callback for RovingAccessibleButton * feat: allow using custom class for ContextMenu * feat: allow setting tab index for avatar * refactor: move read receipts out of event tile * feat: implement new read receipt design * feat: update SentReceipt to match new read receipts as well --- res/css/_components.scss | 1 + res/css/views/right_panel/_TimelineCard.scss | 4 +- res/css/views/rooms/_EventBubbleTile.scss | 6 +- res/css/views/rooms/_EventTile.scss | 44 +-- res/css/views/rooms/_GroupLayout.scss | 2 +- res/css/views/rooms/_IRCLayout.scss | 4 +- res/css/views/rooms/_ReadReceiptGroup.scss | 146 ++++++++++ .../roving/RovingAccessibleButton.tsx | 16 +- .../roving/RovingAccessibleTooltipButton.tsx | 16 +- src/components/structures/ContextMenu.tsx | 3 +- src/components/views/avatars/BaseAvatar.tsx | 1 + src/components/views/avatars/MemberAvatar.tsx | 3 +- src/components/views/elements/Tooltip.tsx | 12 + src/components/views/rooms/EventTile.tsx | 227 ++++------------ .../views/rooms/ReadReceiptGroup.tsx | 254 ++++++++++++++++++ .../views/rooms/ReadReceiptMarker.tsx | 52 +--- src/i18n/strings/en_EN.json | 4 +- src/utils/useTooltip.tsx | 24 ++ 18 files changed, 551 insertions(+), 268 deletions(-) create mode 100644 res/css/views/rooms/_ReadReceiptGroup.scss create mode 100644 src/components/views/rooms/ReadReceiptGroup.tsx create mode 100644 src/utils/useTooltip.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index bcd3111663e..b222e8ce232 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -248,6 +248,7 @@ @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; +@import "./views/rooms/_ReadReceiptGroup.scss"; @import "./views/rooms/_RecentlyViewedButton.scss"; @import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyTile.scss"; diff --git a/res/css/views/right_panel/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 447933a39f9..1b35d16cdd0 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -125,8 +125,8 @@ limitations under the License. padding-left: 36px; } - .mx_EventTile_readAvatars { - top: -10px; + .mx_ReadReceiptGroup { + top: -6px; } .mx_WhoIsTypingTile { diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index ca58c666ccf..4329d37c0eb 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -447,7 +447,7 @@ limitations under the License. } } - .mx_EventTile_readAvatars { + .mx_ReadReceiptGroup { position: absolute; right: -78px; // as close to right gutter without clipping as possible bottom: 0; @@ -585,8 +585,8 @@ limitations under the License. right: 127px; // align with that of right-column bubbles } - .mx_EventTile_readAvatars { - right: -18px; // match alignment to RRs of chat bubbles + .mx_ReadReceiptGroup { + right: -14px; // match alignment to RRs of chat bubbles } &::before { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 497de258dc5..480ac086547 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -22,20 +22,18 @@ $left-gutter: 64px; .mx_EventTile_receiptSent, .mx_EventTile_receiptSending { - // Give it some dimensions so the tooltip can position properly + position: relative; display: inline-block; - width: 14px; - height: 14px; - // We don't use `position: relative` on the element because then it won't line - // up with the other read receipts + width: 16px; + height: 16px; &::before { background-color: $tertiary-content; mask-repeat: no-repeat; mask-position: center; - mask-size: 14px; - width: 14px; - height: 14px; + mask-size: 16px; + width: 16px; + height: 16px; content: ''; position: absolute; top: 0; @@ -349,36 +347,6 @@ $left-gutter: 64px; } } -.mx_EventTile_readAvatars { - position: relative; - display: inline-block; - width: 14px; - height: 14px; - // This aligns the avatar with the last line of the - // message. We want to move it one line up - 2.2rem - top: -2.2rem; - user-select: none; - z-index: 1; -} - -.mx_EventTile_readAvatars .mx_BaseAvatar { - position: absolute; - display: inline-block; - height: $font-14px; - width: $font-14px; - - will-change: left, top; - transition: - left var(--transition-short) ease-out, - top var(--transition-standard) ease-out; -} - -.mx_EventTile_readAvatarRemainder { - color: $event-timestamp-color; - font-size: $font-11px; - position: absolute; -} - .mx_EventTile_bigEmoji { font-size: 48px; line-height: 57px; diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index c054256b6a6..b1d6e8b535b 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -97,7 +97,7 @@ $left-gutter: 64px; top: 3px; } - .mx_EventTile_readAvatars { + .mx_ReadReceiptGroup { // This aligns the avatar with the last line of the // message. We want to move it one line up - 2rem top: -2rem; diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index b0401a447ca..36d045610a3 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -49,8 +49,8 @@ $irc-line-height: $font-18px; order: 5; flex-shrink: 0; - .mx_EventTile_readAvatars { - top: 0.2rem; // ($irc-line-height - avatar height) / 2 + .mx_ReadReceiptGroup { + top: -0.3rem; // ($irc-line-height - avatar height) / 2 } } diff --git a/res/css/views/rooms/_ReadReceiptGroup.scss b/res/css/views/rooms/_ReadReceiptGroup.scss new file mode 100644 index 00000000000..fe40b1263f3 --- /dev/null +++ b/res/css/views/rooms/_ReadReceiptGroup.scss @@ -0,0 +1,146 @@ +/* +Copyright 2022 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_ReadReceiptGroup { + position: relative; + display: inline-block; + // This aligns the avatar with the last line of the + // message. We want to move it one line up + // See .mx_GroupLayout .mx_EventTile .mx_EventTile_line in _GroupLayout.scss + top: calc(-$font-22px - 3px); + user-select: none; + z-index: 1; + + .mx_ReadReceiptGroup_button { + display: inline-flex; + flex-direction: row; + height: 16px; + padding: 4px; + border-radius: 6px; + + &.mx_AccessibleButton { + &:hover { + background: $event-selected-color; + } + } + } + + .mx_ReadReceiptGroup_remainder { + color: $secondary-content; + font-size: $font-11px; + line-height: $font-16px; + margin-right: 4px; + } + + .mx_ReadReceiptGroup_container { + position: relative; + display: block; + height: 100%; + + .mx_BaseAvatar { + position: absolute; + display: inline-block; + height: 14px; + width: 14px; + border: 1px solid $background; + border-radius: 100%; + + will-change: left, top; + transition: + left var(--transition-short) ease-out, + top var(--transition-standard) ease-out; + } + } +} + +.mx_ReadReceiptGroup_popup { + max-height: 300px; + width: 220px; + border-radius: 8px; + display: flex; + flex-direction: column; + text-align: left; + font-size: 12px; + line-height: 15px; + + right: 0; + + &.mx_ContextualMenu_top { + top: 8px; + } + + &.mx_ContextualMenu_bottom { + bottom: 8px; + } + + .mx_ReadReceiptGroup_title { + font-size: 12px; + line-height: 15px; + margin: 16px 16px 8px; + font-weight: 600; + // shouldn’t be actually focusable + outline: none; + } + + .mx_AutoHideScrollbar { + .mx_ReadReceiptGroup_person { + display: flex; + flex-direction: row; + padding: 4px; + margin: 0 12px; + border-radius: 8px; + + &:hover { + background: $menu-selected-color; + } + + &:last-child { + margin-bottom: 8px; + } + + .mx_BaseAvatar { + margin: 6px 8px; + align-self: center; + justify-self: center; + } + + .mx_ReadReceiptGroup_name { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + + p { + margin: 2px 0; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_ReadReceiptGroup_secondary { + color: $secondary-content; + } + } + } + } +} + +.mx_ReadReceiptGroup_person--tooltip { + overflow-y: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index f9ce87db6a7..0d9025dd59f 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -20,13 +20,21 @@ import AccessibleButton from "../../components/views/elements/AccessibleButton"; import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; -interface IProps extends Omit, "onFocus" | "inputRef" | "tabIndex"> { +interface IProps extends Omit, "inputRef" | "tabIndex"> { inputRef?: Ref; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({ inputRef, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return ; +export const RovingAccessibleButton: React.FC = ({ inputRef, onFocus, ...props }) => { + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + return { + onFocusInternal(); + onFocus?.(event); + }} + inputRef={ref} + tabIndex={isActive ? 0 : -1} + />; }; diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index d9e393d7282..432e8880173 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -21,13 +21,21 @@ import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; type ATBProps = React.ComponentProps; -interface IProps extends Omit { +interface IProps extends Omit { inputRef?: Ref; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return ; +export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, onFocus, ...props }) => { + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + return { + onFocusInternal(); + onFocus?.(event); + }} + inputRef={ref} + tabIndex={isActive ? 0 : -1} + />; }; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index a3c214eb456..187e55cc392 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -82,6 +82,7 @@ export interface IProps extends IPosition { // whether this context menu should be focus managed. If false it must handle itself managed?: boolean; wrapperClassName?: string; + menuClassName?: string; // If true, this context menu will be mounted as a child to the parent container. Otherwise // it will be mounted to a container at the root of the DOM. @@ -319,7 +320,7 @@ export default class ContextMenu extends React.PureComponent { 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, 'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, 'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, - }); + }, this.props.menuClassName); const menuStyle: CSSProperties = {}; if (props.menuWidth) { diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 22d2d1ab00d..76eea6cec06 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -45,6 +45,7 @@ interface IProps { onClick?: React.MouseEventHandler; inputRef?: React.RefObject; className?: string; + tabIndex?: number; } const calculateUrls = (url, urls, lowBandwidth) => { diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index c5dd1bfd3c4..09400b7e21d 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -43,6 +43,7 @@ interface IProps extends Omit, "name" | title?: string; style?: any; forceHistorical?: boolean; // true to deny `feature_use_only_current_profiles` usage. Default false. + hideTitle?: boolean; } interface IState { @@ -124,7 +125,7 @@ export default class MemberAvatar extends React.PureComponent { { style.top = baseTop + parentBox.height - 50; style.left = horizontalCenter; style.transform = "translate(-50%)"; + break; + case Alignment.TopRight: + style.top = baseTop - 5; + style.right = width - parentBox.right - window.pageXOffset; + style.transform = "translate(5px, -100%)"; + break; + case Alignment.TopCenter: + style.top = baseTop - 5; + style.left = horizontalCenter; + style.transform = "translate(-50%, -100%)"; } return style; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 4c68ee532e8..f068e029a85 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -36,12 +36,11 @@ import { formatTime } from "../../../DateUtils"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { E2EState } from "./E2EIcon"; -import { toRem } from "../../../utils/units"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import { aboveRightOf } from '../../structures/ContextMenu'; import { objectHasDiff } from "../../../utils/objects"; -import Tooltip from "../elements/Tooltip"; +import Tooltip, { Alignment } from "../elements/Tooltip"; import EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; @@ -54,7 +53,7 @@ import MemberAvatar from '../avatars/MemberAvatar'; import SenderProfile from '../messages/SenderProfile'; import MessageTimestamp from '../messages/MessageTimestamp'; import TooltipButton from '../elements/TooltipButton'; -import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker"; +import { IReadReceiptInfo } from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; import { getEventDisplayInfo } from '../../../utils/EventRenderingUtils'; @@ -79,6 +78,8 @@ import PosthogTrackers from "../../../PosthogTrackers"; import TileErrorBoundary from '../messages/TileErrorBoundary'; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; +import { ReadReceiptGroup } from './ReadReceiptGroup'; +import { useTooltip } from "../../../utils/useTooltip"; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -221,9 +222,6 @@ interface IProps { interface IState { // Whether the action bar is focused. actionBarFocused: boolean; - // Whether all read receipts are being displayed. If not, only display - // a truncation of them. - allReadAvatars: boolean; // Whether the event's sender has been verified. verified: string; // Whether onRequestKeysClick has been called since mounting. @@ -273,9 +271,6 @@ export class UnwrappedEventTile extends React.Component { this.state = { // Whether the action bar is focused. actionBarFocused: false, - // Whether all read receipts are being displayed. If not, only display - // a truncation of them. - allReadAvatars: false, // Whether the event's sender has been verified. verified: null, // Whether onRequestKeysClick has been called since mounting. @@ -731,108 +726,6 @@ export class UnwrappedEventTile extends React.Component { return actions.tweaks.highlight; } - private toggleAllReadAvatars = () => { - this.setState({ - allReadAvatars: !this.state.allReadAvatars, - }); - }; - - private getReadAvatars() { - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { - return ; - } - - const MAX_READ_AVATARS = this.props.layout == Layout.Bubble - ? 2 - : 5; - - // return early if there are no read receipts - if (!this.props.readReceipts || this.props.readReceipts.length === 0) { - // We currently must include `mx_EventTile_readAvatars` in the DOM - // of all events, as it is the positioned parent of the animated - // read receipts. We can't let it unmount when a receipt moves - // events, so for now we mount it for all events. Without it, the - // animation will start from the top of the timeline (because it - // lost its container). - // See also https://github.com/vector-im/element-web/issues/17561 - return ( -
- -
- ); - } - - const avatars = []; - const receiptOffset = 15; - let left = 0; - - const receipts = this.props.readReceipts; - - for (let i = 0; i < receipts.length; ++i) { - const receipt = receipts[i]; - - let hidden = true; - if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) { - hidden = false; - } - // TODO: we keep the extra read avatars in the dom to make animation simpler - // we could optimise this to reduce the dom size. - - // If hidden, set offset equal to the offset of the final visible avatar or - // else set it proportional to index - left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset; - - const userId = receipt.userId; - let readReceiptInfo: IReadReceiptInfo; - - if (this.props.readReceiptMap) { - readReceiptInfo = this.props.readReceiptMap[userId]; - if (!readReceiptInfo) { - readReceiptInfo = {}; - this.props.readReceiptMap[userId] = readReceiptInfo; - } - } - - // add to the start so the most recent is on the end (ie. ends up rightmost) - avatars.unshift( -