From e5ca7954c8f3224967263c1107249a99d5867c67 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Jan 2025 10:44:20 +0000 Subject: [PATCH 1/7] Refactor LegacyCallHandler event emitter to use TypedEventEmitter (#29008) * Switch LegacyCallHandler over to TypedEventEmitter and use emits to notify consumers of protocol support updates Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add test for dialpad Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- playwright/e2e/voip/pstn.spec.ts | 31 ++++++++++++++++++ .../voip/pstn.spec.ts/dialpad-linux.png | Bin 0 -> 13680 bytes .../pstn.spec.ts/dialpad-trigger-linux.png | Bin 0 -> 3236 bytes src/LegacyCallHandler.tsx | 23 ++++++++----- src/components/structures/LeftPanel.tsx | 15 ++++++--- src/components/structures/RoomView.tsx | 2 +- src/components/views/rooms/RoomList.tsx | 11 +++++-- src/dispatcher/actions.ts | 14 -------- 8 files changed, 66 insertions(+), 30 deletions(-) create mode 100644 playwright/e2e/voip/pstn.spec.ts create mode 100644 playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png create mode 100644 playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png diff --git a/playwright/e2e/voip/pstn.spec.ts b/playwright/e2e/voip/pstn.spec.ts new file mode 100644 index 0000000000..9a35d9b9c3 --- /dev/null +++ b/playwright/e2e/voip/pstn.spec.ts @@ -0,0 +1,31 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("PSTN", () => { + test.beforeEach(async ({ page }) => { + // Mock the third party protocols endpoint to look like the HS has PSTN support + await page.route("**/_matrix/client/v3/thirdparty/protocols", async (route) => { + await route.fulfill({ + status: 200, + json: { + "im.vector.protocol.pstn": {}, + }, + }); + }); + }); + + test("should render dialpad as expected", { tag: "@screenshot" }, async ({ page, user, toasts }) => { + await toasts.rejectToast("Notifications"); + await toasts.assertNoToasts(); + + await expect(page.locator(".mx_LeftPanel_filterContainer")).toMatchScreenshot("dialpad-trigger.png"); + await page.getByLabel("Open dial pad").click(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png"); + }); +}); diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..3be63e2f506e4fb0c7d92e8cc0836f347bca21a6 GIT binary patch literal 13680 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 X;_F)AMe+9)DiBIiLEw$ literal 0 HcmV?d00001 diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..17bea8979db806f0129cc83136cc4ea3a814f183 GIT binary patch literal 3236 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 literal 0 HcmV?d00001 diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index c2df066fa2..8aaa32f471 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixError, RuleId, TweakName, SyncState } from "matrix-js-sdk/src/matrix"; +import { MatrixError, RuleId, TweakName, SyncState, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { CallError, CallErrorCode, @@ -22,7 +22,6 @@ import { MatrixCall, } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; -import EventEmitter from "events"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; @@ -137,14 +136,23 @@ export enum LegacyCallHandlerEvent { CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", CallState = "call_state", + ProtocolSupport = "protocol_support", } +type EventEmitterMap = { + [LegacyCallHandlerEvent.CallsChanged]: (calls: Map) => void; + [LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void; + [LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set) => void; + [LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void; + [LegacyCallHandlerEvent.ProtocolSupport]: () => void; +}; + /** * LegacyCallHandler manages all currently active calls. It should be used for * placing, answering, rejecting and hanging up calls. It also handles ringing, * PSTN support and other things. */ -export default class LegacyCallHandler extends EventEmitter { +export default class LegacyCallHandler extends TypedEventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. @@ -271,15 +279,13 @@ export default class LegacyCallHandler extends EventEmitter { this.supportsPstnProtocol = null; } - dis.dispatch({ action: Action.PstnSupportUpdated }); - if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) { this.supportsSipNativeVirtual = Boolean( protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL], ); } - dis.dispatch({ action: Action.VirtualRoomSupportUpdated }); + this.emit(LegacyCallHandlerEvent.ProtocolSupport); } catch (e) { if (maxTries === 1) { logger.log("Failed to check for protocol support and no retries remain: assuming no support", e); @@ -296,8 +302,8 @@ export default class LegacyCallHandler extends EventEmitter { return !!SdkConfig.getObject("voip")?.get("obey_asserted_identity"); } - public getSupportsPstnProtocol(): boolean | null { - return this.supportsPstnProtocol; + public getSupportsPstnProtocol(): boolean { + return this.supportsPstnProtocol ?? false; } public getSupportsVirtualRooms(): boolean | null { @@ -568,6 +574,7 @@ export default class LegacyCallHandler extends EventEmitter { if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return; this.setCallState(call, newState); + // XXX: this is used by the IPC into Electron to keep device awake dis.dispatch({ action: "call_state", room_id: mappedRoomId, diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 92e612a477..de591a6b22 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -13,7 +13,7 @@ import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import RoomList from "../views/rooms/RoomList"; -import LegacyCallHandler from "../../LegacyCallHandler"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; import { HEADER_HEIGHT } from "../views/rooms/RoomSublist"; import { Action } from "../../dispatcher/actions"; import RoomSearch from "./RoomSearch"; @@ -51,6 +51,7 @@ enum BreadcrumbsMode { interface IState { showBreadcrumbs: BreadcrumbsMode; activeSpace: SpaceKey; + supportsPstnProtocol: boolean; } export default class LeftPanel extends React.Component { @@ -65,6 +66,7 @@ export default class LeftPanel extends React.Component { this.state = { activeSpace: SpaceStore.instance.activeSpace, showBreadcrumbs: LeftPanel.breadcrumbsMode, + supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol(), }; } @@ -76,6 +78,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); if (this.listContainerRef.current) { UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); @@ -90,6 +93,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); UIStore.instance.stopTrackingElementDimensions("ListContainer"); UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); @@ -101,6 +105,10 @@ export default class LeftPanel extends React.Component { } } + private updateProtocolSupport = (): void => { + this.setState({ supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol() }); + }; + private updateActiveSpace = (activeSpace: SpaceKey): void => { this.setState({ activeSpace }); }; @@ -330,9 +338,8 @@ export default class LeftPanel extends React.Component { private renderSearchDialExplore(): React.ReactNode { let dialPadButton: JSX.Element | undefined; - // If we have dialer support, show a button to bring up the dial pad - // to start a new call - if (LegacyCallHandler.instance.getSupportsPstnProtocol()) { + // If we have dialer support, show a button to bring up the dial pad to start a new call + if (this.state.supportsPstnProtocol) { dialPadButton = ( { } }; - private onCallState = (roomId: string): void => { + private onCallState = (roomId: string | null): void => { // don't filter out payloads for room IDs other than props.room because // we may be interested in the conf 1:1 room diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index fed1826bbb..22e4f49469 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { EventType, RoomType, Room } from "matrix-js-sdk/src/matrix"; +import { EventType, Room, RoomType } from "matrix-js-sdk/src/matrix"; import React, { ComponentType, createRef, ReactComponentElement, SyntheticEvent } from "react"; import { IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; @@ -56,6 +56,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import AccessibleButton from "../elements/AccessibleButton"; import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler.tsx"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -440,6 +441,7 @@ export default class RoomList extends React.PureComponent { SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); + LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); this.updateLists(); // trigger the first update } @@ -448,8 +450,13 @@ export default class RoomList extends React.PureComponent { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); defaultDispatcher.unregister(this.dispatcherRef); SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); } + private updateProtocolSupport = (): void => { + this.updateLists(); + }; + private onRoomViewStoreUpdate = (): void => { this.setState({ currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined, @@ -471,8 +478,6 @@ export default class RoomList extends React.PureComponent { metricsViaKeyboard: true, }); } - } else if (payload.action === Action.PstnSupportUpdated) { - this.updateLists(); } }; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 5205e5badf..cd8b7aea3d 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -135,20 +135,6 @@ export enum Action { */ OpenDialPad = "open_dial_pad", - /** - * Fired when CallHandler has checked for PSTN protocol support - * payload: none - * XXX: Is an action the right thing for this? - */ - PstnSupportUpdated = "pstn_support_updated", - - /** - * Similar to PstnSupportUpdated, fired when CallHandler has checked for virtual room support - * payload: none - * XXX: Ditto - */ - VirtualRoomSupportUpdated = "virtual_room_support_updated", - /** * Fired when an upload has started. Should be used with UploadStartedPayload. */ From ef1597ff2d2fcfc04d24873d64323f69531a0c0b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Jan 2025 12:26:00 +0000 Subject: [PATCH 2/7] Enable react-compiler eslint to spot antipatterns (#28652) * Switch to React18 useId Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Enable react-compiler eslint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix an easy one Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Disable in tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix usage of useRef as memoization Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix mutation of external values in hooks Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Make React compiler happy about some frankly non-issues Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix MapMock Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Revert MemberListViewModel.tsx changes and disable linter per line Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Make viewmodel compatible with react-compiler linter - Remove searchQuery ref/state and instead pass this query to the loadMember function. - Now we no longer need a separate search function --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: R Midhun Suresh --- .eslintrc.js | 5 ++- __mocks__/maplibre-gl.js | 1 + package.json | 1 + src/accessibility/RovingTabIndex.tsx | 1 + .../structures/AutocompleteInput.tsx | 1 + src/components/structures/ContextMenu.tsx | 1 + src/components/structures/FilePanel.tsx | 4 +-- .../structures/NotificationPanel.tsx | 2 +- src/components/structures/RoomSearchView.tsx | 4 +-- src/components/structures/RoomView.tsx | 5 ++- src/components/structures/ThreadPanel.tsx | 2 +- src/components/structures/ThreadView.tsx | 2 +- src/components/utils/Box.tsx | 35 +++++-------------- src/components/utils/Flex.tsx | 23 ++++++------ .../memberlist/MemberListViewModel.tsx | 31 ++++++---------- .../views/elements/EffectsOverlay.tsx | 3 +- src/components/views/elements/Measured.tsx | 10 +++--- .../views/right_panel/TimelineCard.tsx | 2 +- .../views/rooms/ReadReceiptGroup.tsx | 1 + .../views/rooms/UserIdentityWarning.tsx | 10 +++--- .../wysiwyg_composer/EditWysiwygComposer.tsx | 6 ++-- .../wysiwyg_composer/SendWysiwygComposer.tsx | 9 +++-- .../hooks/useComposerFunctions.ts | 1 + .../hooks/usePlainTextInitialization.ts | 1 + .../views/settings/ThemeChoicePanel.tsx | 6 ++-- .../tabs/room/VoipRoomSettingsTab.tsx | 34 +++++++++--------- .../views/spaces/QuickThemeSwitcher.tsx | 2 +- .../useUnreadThreadRooms.ts | 16 ++++----- src/contexts/ScopedRoomContext.tsx | 11 +++--- src/contexts/ToastContext.tsx | 12 +++---- src/hooks/useAsyncRefreshMemo.ts | 2 +- src/hooks/useRoomNotificationState.ts | 1 + src/hooks/useTransition.ts | 2 +- src/utils/location/useMap.ts | 7 ++-- yarn.lock | 28 +++++++++++++-- 35 files changed, 146 insertions(+), 136 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 28d26696cb..f310384972 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ["matrix-org"], + plugins: ["matrix-org", "eslint-plugin-react-compiler"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], parserOptions: { project: ["./tsconfig.json"], @@ -170,6 +170,8 @@ module.exports = { "jsx-a11y/role-supports-aria-props": "off", "matrix-org/require-copyright-header": "error", + + "react-compiler/react-compiler": "error", }, overrides: [ { @@ -262,6 +264,7 @@ module.exports = { // These are fine in tests "no-restricted-globals": "off", + "react-compiler/react-compiler": "off", }, }, { diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index cac71db330..475648e774 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -17,6 +17,7 @@ class MockMap extends EventEmitter { setCenter = jest.fn(); setStyle = jest.fn(); fitBounds = jest.fn(); + remove = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/package.json b/package.json index d79de6e081..2fb13e438c 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,7 @@ "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-matrix-org": "^2.0.2", "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unicorn": "^56.0.0", "express": "^4.18.2", diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index dada99b3e7..e2227ea42d 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -392,6 +392,7 @@ export const useRovingTabIndex = ( }); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler const isActive = context.state.activeNode === nodeRef.current; return [onFocus, isActive, ref, nodeRef]; }; diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index b25e93bc75..25e0d7d1a1 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -142,6 +142,7 @@ export const AutocompleteInput: React.FC = ({ {isFocused && suggestions.length ? (
diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 3d0c169267..51aef8f454 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -607,6 +607,7 @@ export const useContextMenu = (inputRef?: RefObject setIsOpen(false); }; + // eslint-disable-next-line react-compiler/react-compiler return [button.current ? isOpen : false, button, open, close, setIsOpen]; }; diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 32e5bbc519..c1eb34597f 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -286,9 +286,7 @@ class FilePanel extends React.Component { ref={this.card} header={_t("right_panel|files_button")} > - {this.card.current && ( - - )} + - {this.card.current && } + {content} diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index ca67ca6bbf..14f34c9146 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react"; +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { ISearchResults, IThreadBundledRelationship, @@ -58,7 +58,7 @@ export const RoomSearchView = forwardRef( const [results, setResults] = useState(null); const aborted = useRef(false); // A map from room ID to permalink creator - const permalinkCreators = useRef(new Map()).current; + const permalinkCreators = useMemo(() => new Map(), []); const innerRef = useRef(); useEffect(() => { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index f66dbe3906..fe51b60564 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -273,6 +273,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { } const onRetryClicked = (): void => { + // eslint-disable-next-line react-compiler/react-compiler room.state = LocalRoomState.NEW; defaultDispatcher.dispatch({ action: "local_room_event", @@ -2514,9 +2515,7 @@ export class RoomView extends React.Component { mainSplitContentClassName = "mx_MainSplit_timeline"; mainSplitBody = ( <> - {this.roomViewBody.current && ( - - )} + {auxPanel} {pinnedMessageBanner}
diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 7aee8554b1..f6742d8159 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -204,7 +204,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => ref={card} closeButtonRef={closeButonRef} > - {card.current && } + {timelineSet ? ( { PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev); }} > - {this.card.current && } +
{timeline}
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( diff --git a/src/components/utils/Box.tsx b/src/components/utils/Box.tsx index c81c9bafed..2de64ba075 100644 --- a/src/components/utils/Box.tsx +++ b/src/components/utils/Box.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { useEffect, useRef } from "react"; +import React, { useMemo } from "react"; type FlexProps = { /** @@ -40,25 +40,6 @@ type FlexProps = { grow?: string | null; }; -/** - * Set or remove a CSS property - * @param ref the reference - * @param name the CSS property name - * @param value the CSS property value - */ -function addOrRemoveProperty( - ref: React.MutableRefObject, - name: string, - value?: string | null, -): void { - const style = ref.current!.style; - if (value) { - style.setProperty(name, value); - } else { - style.removeProperty(name); - } -} - /** * A flex child helper */ @@ -71,12 +52,12 @@ export function Box({ children, ...props }: React.PropsWithChildren): JSX.Element { - const ref = useRef(); - - useEffect(() => { - addOrRemoveProperty(ref, `--mx-box-flex`, flex); - addOrRemoveProperty(ref, `--mx-box-shrink`, shrink); - addOrRemoveProperty(ref, `--mx-box-grow`, grow); + const style = useMemo(() => { + const style: Record = {}; + if (flex) style["--mx-box-flex"] = flex; + if (shrink) style["--mx-box-shrink"] = shrink; + if (grow) style["--mx-box-grow"] = grow; + return style; }, [flex, grow, shrink]); return React.createElement( @@ -88,7 +69,7 @@ export function Box({ "mx_Box--shrink": !!shrink, "mx_Box--grow": !!grow, }), - ref, + style, }, children, ); diff --git a/src/components/utils/Flex.tsx b/src/components/utils/Flex.tsx index ae5704d247..3788e32c45 100644 --- a/src/components/utils/Flex.tsx +++ b/src/components/utils/Flex.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { useEffect, useRef } from "react"; +import React, { useMemo } from "react"; type FlexProps = { /** @@ -64,15 +64,16 @@ export function Flex({ children, ...props }: React.PropsWithChildren): JSX.Element { - const ref = useRef(); + const style = useMemo( + () => ({ + "--mx-flex-display": display, + "--mx-flex-direction": direction, + "--mx-flex-align": align, + "--mx-flex-justify": justify, + "--mx-flex-gap": gap, + }), + [align, direction, display, gap, justify], + ); - useEffect(() => { - ref.current!.style.setProperty(`--mx-flex-display`, display); - ref.current!.style.setProperty(`--mx-flex-direction`, direction); - ref.current!.style.setProperty(`--mx-flex-align`, align); - ref.current!.style.setProperty(`--mx-flex-justify`, justify); - ref.current!.style.setProperty(`--mx-flex-gap`, gap); - }, [align, direction, display, gap, justify]); - - return React.createElement(as, { ...props, className: classNames("mx_Flex", className), ref }, children); + return React.createElement(as, { ...props, className: classNames("mx_Flex", className), style }, children); } diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index 4a1a2d59f1..88eacb1b93 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -19,7 +19,7 @@ import { UserEvent, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { throttle } from "lodash"; import { RoomMember } from "../../../models/rooms/RoomMember"; @@ -120,19 +120,16 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { const sdkContext = useContext(SDKContext); const [memberMap, setMemberMap] = useState>(new Map()); const [isLoading, setIsLoading] = useState(true); - // This is the last known total number of members in this room. - const totalMemberCount = useRef(0); - - const searchQuery = useRef(""); + const [totalMemberCount, setTotalMemberCount] = useState(0); const loadMembers = useMemo( () => throttle( - async (): Promise => { + async (searchQuery?: string): Promise => { const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList( roomId, - searchQuery.current, + searchQuery, ); const newMemberMap = new Map(); // First add the invited room members @@ -141,7 +138,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { newMemberMap.set(member.userId, roomMember); } // Then add the third party invites - const threePidInvited = getPending3PidInvites(room, searchQuery.current); + const threePidInvited = getPending3PidInvites(room, searchQuery); for (const invited of threePidInvited) { const key = invited.threePidInvite!.event.getContent().display_name; newMemberMap.set(key, invited); @@ -152,26 +149,18 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { newMemberMap.set(member.userId, roomMember); } setMemberMap(newMemberMap); - if (!searchQuery.current) { + if (!searchQuery) { /** * Since searching for members only gives you the relevant * members matching the query, do not update the totalMemberCount! **/ - totalMemberCount.current = newMemberMap.size; + setTotalMemberCount(newMemberMap.size); } }, 500, { leading: true, trailing: true }, ), - [roomId, sdkContext.memberListStore, room], - ); - - const search = useCallback( - (query: string) => { - searchQuery.current = query; - loadMembers(); - }, - [loadMembers], + [sdkContext.memberListStore, roomId, room], ); const isPresenceEnabled = useMemo( @@ -252,12 +241,12 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { return { members: Array.from(memberMap.values()), - search, + search: loadMembers, shouldShowInvite, isPresenceEnabled, isLoading, onInviteButtonClick, - shouldShowSearch: totalMemberCount.current >= 20, + shouldShowSearch: totalMemberCount >= 20, canInvite, }; } diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx index 68733b4ceb..746a135390 100644 --- a/src/components/views/elements/EffectsOverlay.tsx +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -58,11 +58,10 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { if (canvas) canvas.height = UIStore.instance.windowHeight; UIStore.instance.on(UI_EVENTS.Resize, resize); + const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored return () => { dis.unregister(dispatcherRef); UIStore.instance.off(UI_EVENTS.Resize, resize); - // eslint-disable-next-line react-hooks/exhaustive-deps - const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored for (const effect in currentEffects) { const effectModule: ICanvasEffect = currentEffects.get(effect)!; if (effectModule && effectModule.isRunning) { diff --git a/src/components/views/elements/Measured.tsx b/src/components/views/elements/Measured.tsx index 6a4abae2de..5f0d3acca8 100644 --- a/src/components/views/elements/Measured.tsx +++ b/src/components/views/elements/Measured.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { RefObject } from "react"; import UIStore, { UI_EVENTS } from "../../../stores/UIStore"; interface IProps { - sensor: Element; + sensor: RefObject; breakpoint: number; onMeasurement(narrow: boolean): void; } @@ -35,14 +35,14 @@ export default class Measured extends React.PureComponent { } public componentDidUpdate(prevProps: Readonly): void { - const previous = prevProps.sensor; - const current = this.props.sensor; + const previous = prevProps.sensor.current; + const current = this.props.sensor.current; if (previous === current) return; if (previous) { UIStore.instance.stopTrackingElementDimensions(`Measured${this.instanceId}`); } if (current) { - UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, this.props.sensor); + UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, this.props.sensor.current); } } diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index 49b313ca44..4cee1ef9b4 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -213,7 +213,7 @@ export default class TimelineCard extends React.Component { header={_t("right_panel|video_room_chat|title")} ref={this.card} > - {this.card.current && } +
{jumpToBottom} = ({ room } initialisedRef.current = InitialisationStatus.Completed; }, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]); - loadMembers().catch((e) => { - logger.error("Error initialising UserIdentityWarning:", e); - }); + useEffect(() => { + loadMembers().catch((e) => { + logger.error("Error initialising UserIdentityWarning:", e); + }); + }, [loadMembers]); // When a user's verification status changes, we check if they need to be // added/removed from the set of members needing approval. diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 25822b9176..2de986a299 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; +import React, { ForwardedRef, forwardRef, MutableRefObject, useMemo } from "react"; import classNames from "classnames"; import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; @@ -44,7 +44,7 @@ export default function EditWysiwygComposer({ className, ...props }: EditWysiwygComposerProps): JSX.Element { - const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer })); + const defaultContextValue = useMemo(() => getDefaultContextValue({ editorStateTransfer }), [editorStateTransfer]); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; @@ -55,7 +55,7 @@ export default function EditWysiwygComposer({ } return ( - + getDefaultContextValue({ eventRelation: props.eventRelation }), + [props.eventRelation], + ); return ( - + } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts index f5219c6543..20d877271e 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts @@ -21,6 +21,7 @@ export function useComposerFunctions( () => ({ clear: () => { if (ref.current) { + // eslint-disable-next-line react-compiler/react-compiler ref.current.innerHTML = ""; } }, diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts index 52613b6b2a..bc58160ce3 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts @@ -12,6 +12,7 @@ export function usePlainTextInitialization(initialContent = "", ref: RefObject { // always read and write the ref.current using .innerHTML for consistency in linebreak and HTML entity handling if (ref.current) { + // eslint-disable-next-line react-compiler/react-compiler ref.current.innerHTML = initialContent; } }, [ref, initialContent]); diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index 0e3926d7e8..b60b3fe540 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, JSX, useCallback, useMemo, useRef, useState } from "react"; +import React, { ChangeEvent, JSX, useCallback, useMemo, useState } from "react"; import { InlineField, ToggleControl, @@ -39,12 +39,12 @@ import { useSettingValue } from "../../../hooks/useSettings"; */ export function ThemeChoicePanel(): JSX.Element { const themeState = useTheme(); - const themeWatcher = useRef(new ThemeWatcher()); + const themeWatcher = useMemo(() => new ThemeWatcher(), []); const customThemeEnabled = useSettingValue("feature_custom_themes"); return ( - {themeWatcher.current.isSystemThemeSupported() && ( + {themeWatcher.isSystemThemeSupported() && ( )} diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx index 2a8425823b..f7b69696be 100644 --- a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { useCallback, useMemo, useState } from "react"; import { JoinRule, EventType, RoomState, Room } from "matrix-js-sdk/src/matrix"; +import { RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types"; import { _t } from "../../../../../languageHandler"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; @@ -24,48 +25,49 @@ interface ElementCallSwitchProps { const ElementCallSwitch: React.FC = ({ room }) => { const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); - const [content, events, maySend] = useRoomState( + const [content, maySend] = useRoomState( room, useCallback( (state: RoomState) => { - const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + const content = state + ?.getStateEvents(EventType.RoomPowerLevels, "") + ?.getContent(); return [ content ?? {}, - content?.["events"] ?? {}, state?.maySendStateEvent(EventType.RoomPowerLevels, room.client.getSafeUserId()), - ]; + ] as const; }, [room.client], ), ); const [elementCallEnabled, setElementCallEnabled] = useState(() => { - return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0; + return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0; }); const onChange = useCallback( (enabled: boolean): void => { setElementCallEnabled(enabled); + // Take a copy to avoid mutating the original + const newContent = { events: {}, ...content }; + if (enabled) { - const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0; + const userLevel = newContent.events[EventType.RoomMessage] ?? content.users_default ?? 0; const moderatorLevel = content.kick ?? 50; - events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; - events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; + newContent.events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; + newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; } else { - const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; + const adminLevel = newContent.events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; - events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; - events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; + newContent.events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; + newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; } - room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, { - events: events, - ...content, - }); + room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, newContent); }, - [room.client, room.roomId, content, events, isPublic], + [room.client, room.roomId, content, isPublic], ); const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; diff --git a/src/components/views/spaces/QuickThemeSwitcher.tsx b/src/components/views/spaces/QuickThemeSwitcher.tsx index 1eff342ac3..5d2372647e 100644 --- a/src/components/views/spaces/QuickThemeSwitcher.tsx +++ b/src/components/views/spaces/QuickThemeSwitcher.tsx @@ -27,7 +27,7 @@ type Props = { const MATCH_SYSTEM_THEME_ID = "MATCH_SYSTEM_THEME_ID"; const QuickThemeSwitcher: React.FC = ({ requestClose }) => { - const orderedThemes = useMemo(getOrderedThemes, []); + const orderedThemes = useMemo(() => getOrderedThemes(), []); const themeState = useTheme(); const nonHighContrast = findNonHighContrastTheme(themeState.theme); diff --git a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts index 94486fdf76..dea00bafa7 100644 --- a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts +++ b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ClientEvent, MatrixClient, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -42,14 +42,12 @@ export function useUnreadThreadRooms(forceComputation: boolean): Result { setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs)); }, [mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs]); - // The exhautive deps lint rule can't compute dependencies here since it's not a plain inline func. - // We make this as simple as possible so its only dep is doUpdate itself. - // eslint-disable-next-line react-hooks/exhaustive-deps - const scheduleUpdate = useCallback( - throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { - leading: false, - trailing: true, - }), + const scheduleUpdate = useMemo( + () => + throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { + leading: false, + trailing: true, + }), [doUpdate], ); diff --git a/src/contexts/ScopedRoomContext.tsx b/src/contexts/ScopedRoomContext.tsx index 2b1827952d..f08911cbb2 100644 --- a/src/contexts/ScopedRoomContext.tsx +++ b/src/contexts/ScopedRoomContext.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; -import React, { ContextType, createContext, memo, ReactNode, useContext, useEffect, useRef, useState } from "react"; +import React, { ContextType, createContext, memo, ReactNode, useContext, useEffect, useMemo, useState } from "react"; import { objectKeyChanges } from "../utils/objects.ts"; import { useTypedEventEmitter } from "../hooks/useEventEmitter.ts"; @@ -48,15 +48,16 @@ const ScopedRoomContext = createContext | undefin // Uses react memo and leverages splatting the value to ensure that the context is only updated when the state changes (shallow compare) export const ScopedRoomContextProvider = memo( ({ children, ...state }: { children: ReactNode } & ContextValue): JSX.Element => { - const contextRef = useRef(new EfficientContext(state)); + // eslint-disable-next-line react-compiler/react-compiler,react-hooks/exhaustive-deps + const context = useMemo(() => new EfficientContext(state), []); useEffect(() => { - contextRef.current.setState(state); - }, [state]); + context.setState(state); + }, [context, state]); // Includes the legacy RoomContext provider for backwards compatibility with class components return ( - {children} + {children} ); }, diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index 100eb6add0..268539d0e7 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { ReactNode, createContext, useCallback, useContext, useEffect, useState, useMemo } from "react"; /** * A ToastContext helps components display any kind of toast message and can be provided @@ -33,19 +33,19 @@ export function useToastContext(): ToastRack { * the ToastRack object that should be provided to the context */ export function useActiveToast(): [ReactNode | undefined, ToastRack] { - const toastRack = useRef(new ToastRack()); + const toastRack = useMemo(() => new ToastRack(), []); - const [activeToast, setActiveToast] = useState(toastRack.current.getActiveToast()); + const [activeToast, setActiveToast] = useState(toastRack.getActiveToast()); const updateCallback = useCallback(() => { - setActiveToast(toastRack.current.getActiveToast()); + setActiveToast(toastRack.getActiveToast()); }, [setActiveToast, toastRack]); useEffect(() => { - toastRack.current.setCallback(updateCallback); + toastRack.setCallback(updateCallback); }, [toastRack, updateCallback]); - return [activeToast, toastRack.current]; + return [activeToast, toastRack]; } interface DisplayedToast { diff --git a/src/hooks/useAsyncRefreshMemo.ts b/src/hooks/useAsyncRefreshMemo.ts index 646217bb63..3d167d56a6 100644 --- a/src/hooks/useAsyncRefreshMemo.ts +++ b/src/hooks/useAsyncRefreshMemo.ts @@ -34,7 +34,7 @@ export function useAsyncRefreshMemo(fn: Fn, deps: DependencyList, initialV return () => { discard = true; }; - }, deps); // eslint-disable-line react-hooks/exhaustive-deps + }, deps); // eslint-disable-line react-hooks/exhaustive-deps,react-compiler/react-compiler useEffect(refresh, [refresh]); return [value, refresh]; } diff --git a/src/hooks/useRoomNotificationState.ts b/src/hooks/useRoomNotificationState.ts index c964501aa9..e76295a347 100644 --- a/src/hooks/useRoomNotificationState.ts +++ b/src/hooks/useRoomNotificationState.ts @@ -25,6 +25,7 @@ export const useNotificationState = (room: Room): [RoomNotifState | undefined, ( setNotificationState(echoChamber.notificationVolume); } }); + // eslint-disable-next-line react-compiler/react-compiler const setter = useCallback((state: RoomNotifState) => (echoChamber.notificationVolume = state), [echoChamber]); return [notificationState, setter]; }; diff --git a/src/hooks/useTransition.ts b/src/hooks/useTransition.ts index e583ba8557..e1f48eeac4 100644 --- a/src/hooks/useTransition.ts +++ b/src/hooks/useTransition.ts @@ -22,6 +22,6 @@ export const useTransition = (callback: (...params: D) useEffect(() => { if (args.current !== null) func.current(...args.current); args.current = deps; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler,react-hooks/exhaustive-deps }, deps); }; diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts index e859559a52..4a299101bd 100644 --- a/src/utils/location/useMap.ts +++ b/src/utils/location/useMap.ts @@ -30,8 +30,10 @@ export const useMap = ({ interactive, bodyId, onError }: UseMapProps): MapLibreM useEffect( () => { + let map: MapLibreMap | undefined; try { - setMap(createMap(cli, !!interactive, bodyId, onError)); + map = createMap(cli, !!interactive, bodyId, onError); + setMap(map); } catch (error) { console.error("Error encountered in useMap", error); if (error instanceof Error) { @@ -46,8 +48,7 @@ export const useMap = ({ interactive, bodyId, onError }: UseMapProps): MapLibreM }; }, // map is excluded as a dependency - // eslint-disable-next-line react-hooks/exhaustive-deps - [interactive, bodyId, onError], + [cli, interactive, bodyId, onError], ); return map; diff --git a/yarn.lock b/yarn.lock index cbe07be2a1..1322ab213f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,7 +61,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.3.tgz#99488264a56b2aded63983abd6a417f03b92ed02" integrity sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g== -"@babel/core@^7.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.23.9": +"@babel/core@^7.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.23.9", "@babel/core@^7.24.4": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.0.tgz#d78b6023cc8f3114ccf049eb219613f74a747b40" integrity sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg== @@ -303,7 +303,7 @@ dependencies: "@babel/types" "^7.25.8" -"@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.3": +"@babel/parser@^7.24.4", "@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.3": version "7.26.3" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.3.tgz#8c51c5db6ddf08134af1ddbacf16aaab48bac234" integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA== @@ -5977,6 +5977,18 @@ eslint-plugin-matrix-org@^2.0.2: resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-2.0.2.tgz#95b86b0f16704ab19740f7c3c62eae69e20365e6" integrity sha512-cQy5Rjeq6uyu1mLXlPZwEJdyM0NmclrnEz68y792FSuuxzMyJNNYLGDQ5CkYW8H+PrD825HUFZ34pNXnjMOzOw== +eslint-plugin-react-compiler@^19.0.0-beta-df7b47d-20241124: + version "19.0.0-beta-df7b47d-20241124" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.0.0-beta-df7b47d-20241124.tgz#468751d3a8a6781189405ee56b39b80545306df8" + integrity sha512-82PfnllC8jP/68KdLAbpWuYTcfmtGLzkqy2IW85WopKMTr+4rdQpp+lfliQ/QE79wWrv/dRoADrk3Pdhq25nTw== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + "@babel/plugin-transform-private-methods" "^7.25.9" + hermes-parser "^0.25.1" + zod "^3.22.4" + zod-validation-error "^3.0.3" + eslint-plugin-react-hooks@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz#72e2eefbac4b694f5324154619fee44f5f60f101" @@ -6936,6 +6948,18 @@ he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hermes-estree@0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" + integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + +hermes-parser@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1" + integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + dependencies: + hermes-estree "0.25.1" + highlight.js@^11.3.1: version "11.10.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92" From 58f812ffe631015e79b0a20afcf507919449203c Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 Jan 2025 13:17:18 +0000 Subject: [PATCH 3/7] Add toast for recovery keys being out of sync (#28946) * Refine `SettingsSection` & `SettingsTab` * Add encryption tab * Add recovery section * Add device verification * Rename `Panel` into `State` * Update & add tests to user settings common * Add tests to `RecoveryPanel` * Add tests to `ChangeRecoveryKey` * Update CreateSecretStorageDialog-test snapshot * Add tests to `EncryptionUserSettingsTab` * Update existing screenshots of e2e tests * Add new encryption tab ownership to `@element-hq/element-crypto-web-reviewers` * Add e2e tests * Fix monospace font and add figma link to hardcoded value * Add unit to Icon * Improve e2e doc * Assert that the crypto module is defined * Add classname doc * Fix typo * Use `good` state instead of default * Rename `ChangeRecoveryKey.isSetupFlow` into `ChangeRecoveryKey.userHasKeyBackup` * Move `deleteCachedSecrets` fixture in `recovery.spec.ts` * Use one callback instead of two in `RecoveryPanel` * Fix docs and naming of `utils.createBot` * Fix typo in `RecoveryPanel` * Add more doc to the state of the `EncryptionUserSettingsTab` * Rename `verification_required` into `set_up_encryption` * Update test * ADd new license * Very early WIP of rejigged e2e error toast code * Update comments and doc * Assert that `recoveryKey.encodedPrivateKey` is always defined * Add comments to explain how the secrets could be uncached * Use `matrixClient.secretStorage.getDefaultKeyId` instead of `matrixClient.getCrypto().checkKeyBackupAndEnable` to know if we need to set up a recovery key * Update existing screenshot to add encryption tab. * Fix tests * Remove unused file! * Remove test for unused file * Show 'set up encryption' in the 'other' case. * Test 'key storage out of sync' toast * Update tests * Fix test & make toast look correct * Use new labels when changing the recovery key * Fix docs * Don't reset key backup when creating a recovery key * Add playwright test for toast * Dismiss the toast as it's now in the way due to being wider * Doesn't look like this needs to be async * Typo Co-authored-by: Andy Balaam * Typo Co-authored-by: Andy Balaam * Override width for just this toast --------- Co-authored-by: Florian Duros Co-authored-by: Florian Duros Co-authored-by: Andy Balaam --- playwright/e2e/crypto/event-shields.spec.ts | 3 + playwright/e2e/crypto/utils.ts | 22 ++++++ playwright/e2e/room/room-header.spec.ts | 4 + .../encryption-user-tab/recovery.spec.ts | 24 +----- .../key-storage-out-of-sync-toast-linux.png | Bin 0 -> 19162 bytes src/DeviceListener.ts | 72 ++++++++++-------- src/components/views/toasts/GenericToast.tsx | 6 +- src/i18n/strings/en_EN.json | 4 + src/toasts/SetupEncryptionToast.ts | 14 ++++ src/utils/login.ts | 17 ----- test/unit-tests/DeviceListener-test.ts | 19 ++--- .../toasts/SetupEncryptionToast-test.tsx | 8 +- test/unit-tests/utils/login-test.ts | 22 ------ 13 files changed, 109 insertions(+), 106 deletions(-) create mode 100644 playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png delete mode 100644 src/utils/login.ts delete mode 100644 test/unit-tests/utils/login-test.ts diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index 3811c2819e..c0f1e280a2 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -66,6 +66,9 @@ test.describe("Cryptography", function () { // Bob has a second, not cross-signed, device const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + // Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list + await page.getByRole("button", { name: "Not now" }).click(); + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { algorithm: "m.megolm.v1.aes-sha2", ciphertext: "the bird is in the hand", diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index c880961964..7474c5a435 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -413,3 +413,25 @@ export async function createSecondBotDevice(page: Page, homeserver: HomeserverIn await bobSecondDevice.prepareClient(); return bobSecondDevice; } + +/** + * Remove the cached secrets from the indexedDB + * This is a workaround to simulate the case where the secrets are not cached. + */ +export async function deleteCachedSecrets(page: Page) { + await page.evaluate(async () => { + const removeCachedSecrets = new Promise((resolve) => { + const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto"); + request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => { + const db = event.target.result; + const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity"); + request.onsuccess = () => { + db.close(); + resolve(undefined); + }; + }; + }); + await removeCachedSecrets; + }); + await page.reload(); +} diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 7fe0cb3d47..f19bd68f14 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -111,6 +111,10 @@ test.describe("Room Header", () => { async ({ page, app, user }) => { await createVideoRoom(page, app); + // Dismiss a toast that is otherwise in the way (it's the other + // side but there's no need to have it in the screenshot) + await page.getByRole("button", { name: "Later" }).click(); + const header = page.locator(".mx_RoomHeader"); // There's two room info button - the header itself and the i button diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index e6812cd450..a322d42d4e 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -6,13 +6,13 @@ */ import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; -import { Page } from "@playwright/test"; import { test, expect } from "."; import { checkDeviceIsConnectedKeyBackup, checkDeviceIsCrossSigned, createBot, + deleteCachedSecrets, verifySession, } from "../../crypto/utils"; @@ -154,25 +154,3 @@ test.describe("Recovery section in Encryption tab", () => { }, ); }); - -/** - * Remove the cached secrets from the indexedDB - * This is a workaround to simulate the case where the secrets are not cached. - */ -async function deleteCachedSecrets(page: Page) { - await page.evaluate(async () => { - const removeCachedSecrets = new Promise((resolve) => { - const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto"); - request.onsuccess = async (event: Event & { target: { result: IDBDatabase } }) => { - const db = event.target.result; - const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity"); - request.onsuccess = () => { - db.close(); - resolve(undefined); - }; - }; - }); - await removeCachedSecrets; - }); - await page.reload(); -} diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..8e335bd2323b56429f8b03fdbcefd5ed77e9d6f2 GIT binary patch literal 19162 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 literal 0 HcmV?d00001 diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 28bb5f655e..e50f0d3f9b 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -34,11 +34,9 @@ import { hideToast as hideUnverifiedSessionsToast, showToast as showUnverifiedSessionsToast, } from "./toasts/UnverifiedSessionToast"; -import { accessSecretStorage, isSecretStorageBeingAccessed } from "./SecurityManager"; -import { isSecureBackupRequired } from "./utils/WellKnownUtils"; +import { isSecretStorageBeingAccessed } from "./SecurityManager"; import { ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; -import { isLoggedIn } from "./utils/login"; import SdkConfig from "./SdkConfig"; import PlatformPeg from "./PlatformPeg"; import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation"; @@ -283,7 +281,21 @@ export default class DeviceListener { const crossSigningReady = await crypto.isCrossSigningReady(); const secretStorageReady = await crypto.isSecretStorageReady(); - const allSystemsReady = crossSigningReady && secretStorageReady; + const crossSigningStatus = await crypto.getCrossSigningStatus(); + const allCrossSigningSecretsCached = + crossSigningStatus.privateKeysCachedLocally.masterKey && + crossSigningStatus.privateKeysCachedLocally.selfSigningKey && + crossSigningStatus.privateKeysCachedLocally.userSigningKey; + + const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + + const isCurrentDeviceTrusted = + crossSigningReady && + Boolean( + (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, + ); + + const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached; await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { @@ -294,31 +306,31 @@ export default class DeviceListener { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); - // cross signing isn't enabled - nag to enable it - // There are 3 different toasts for: - if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) { - // Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session) + if (!crossSigningReady) { + // This account is legacy and doesn't have cross-signing set up at all. + // Prompt the user to set it up. + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + } else if (!isCurrentDeviceTrusted) { + // cross signing is ready but the current device is not trusted: prompt the user to verify showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); - this.checkKeyBackupStatus(); + } else if (!allCrossSigningSecretsCached) { + // cross signing ready & device trusted, but we are missing secrets from our local cache. + // prompt the user to enter their recovery key. + showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + } else if (defaultKeyId === null) { + // the user just hasn't set up 4S yet: prompt them to do so + showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { - const backupInfo = await this.getKeyBackupInfo(); - if (backupInfo) { - // Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery. - // Since we now enable key backup at registration time, this will be the common case for - // new users. - showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); - } else { - // Toast 3: No cross-signing or key backup on account (set up encryption) - await cli.waitForClientWellKnown(); - if (isSecureBackupRequired(cli) && isLoggedIn()) { - // If we're meant to set up, and Secure Backup is required, - // trigger the flow directly without a toast once logged in. - hideSetupEncryptionToast(); - accessSecretStorage(); - } else { - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); - } - } + // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did + // in 'other' situations. Possibly we should consider prompting for a full reset in this case? + logger.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", { + crossSigningReady, + secretStorageReady, + allCrossSigningSecretsCached, + isCurrentDeviceTrusted, + defaultKeyId, + }); + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); } } @@ -334,12 +346,6 @@ export default class DeviceListener { // Unverified devices that have appeared since then const newUnverifiedDeviceIds = new Set(); - const isCurrentDeviceTrusted = - crossSigningReady && - Boolean( - (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, - ); - // as long as cross-signing isn't ready, // you can't see or dismiss any device toasts if (crossSigningReady) { diff --git a/src/components/views/toasts/GenericToast.tsx b/src/components/views/toasts/GenericToast.tsx index 0e249cecdc..61c6272377 100644 --- a/src/components/views/toasts/GenericToast.tsx +++ b/src/components/views/toasts/GenericToast.tsx @@ -25,6 +25,9 @@ interface IPropsExtended extends IProps { SecondaryIcon?: ComponentType>; destructive?: "primary" | "secondary"; onSecondaryClick(): void; + + // If set, this will override the max-width (of the description) making the toast wider or narrower than standard + overrideWidth?: string; } const GenericToast: React.FC> = ({ @@ -37,12 +40,13 @@ const GenericToast: React.FC> = ({ destructive, onPrimaryClick, onSecondaryClick, + overrideWidth, }) => { const detailContent = detail ?
{detail}
: null; return (
-
+
{description} {detailContent}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 37a739a62e..875da43f14 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -879,14 +879,18 @@ "title": "Destroy cross-signing keys?", "warning": "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from." }, + "enter_recovery_key": "Enter recovery key", "event_shield_reason_authenticity_not_guaranteed": "The authenticity of this encrypted message can't be guaranteed on this device.", "event_shield_reason_mismatched_sender_key": "Encrypted by an unverified session", "event_shield_reason_unknown_device": "Encrypted by an unknown or deleted device.", "event_shield_reason_unsigned_device": "Encrypted by a device not verified by its owner.", "event_shield_reason_unverified_identity": "Encrypted by an unverified user.", "export_unsupported": "Your browser does not support the required cryptography extensions", + "forgot_recovery_key": "Forgot recovery key?", "import_invalid_keyfile": "Not a valid %(brand)s keyfile", "import_invalid_passphrase": "Authentication check failed: incorrect password?", + "key_storage_out_of_sync": "Your key storage is out of sync.", + "key_storage_out_of_sync_description": "Confirm your recovery key to maintain access to your key storage and message history.", "messages_not_secure": { "cause_1": "Your homeserver", "cause_2": "The homeserver the user you're verifying is connected to", diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index ecbf99f4b2..3b8e85eb44 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -27,6 +27,8 @@ const getTitle = (kind: Kind): string => { return _t("encryption|set_up_recovery"); case Kind.VERIFY_THIS_SESSION: return _t("encryption|verify_toast_title"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|key_storage_out_of_sync"); } }; @@ -37,6 +39,7 @@ const getIcon = (kind: Kind): string | undefined => { case Kind.SET_UP_RECOVERY: return undefined; case Kind.VERIFY_THIS_SESSION: + case Kind.KEY_STORAGE_OUT_OF_SYNC: return "verification_warning"; } }; @@ -49,6 +52,8 @@ const getSetupCaption = (kind: Kind): string => { return _t("action|continue"); case Kind.VERIFY_THIS_SESSION: return _t("action|verify"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|enter_recovery_key"); } }; @@ -59,6 +64,8 @@ const getSecondaryButtonLabel = (kind: Kind): string => { case Kind.SET_UP_ENCRYPTION: case Kind.VERIFY_THIS_SESSION: return _t("encryption|verification|unverified_sessions_toast_reject"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|forgot_recovery_key"); } }; @@ -70,6 +77,8 @@ const getDescription = (kind: Kind): string => { return _t("encryption|set_up_recovery_toast_description"); case Kind.VERIFY_THIS_SESSION: return _t("encryption|verify_toast_description"); + case Kind.KEY_STORAGE_OUT_OF_SYNC: + return _t("encryption|key_storage_out_of_sync_description"); } }; @@ -89,6 +98,10 @@ export enum Kind { * Prompt the user to verify this session */ VERIFY_THIS_SESSION = "verify_this_session", + /** + * Prompt the user to enter their recovery key + */ + KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync", } const onReject = (): void => { @@ -139,6 +152,7 @@ export const showToast = (kind: Kind): void => { onPrimaryClick: onAccept, secondaryLabel: getSecondaryButtonLabel(kind), onSecondaryClick: onReject, + overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined, }, component: GenericToast, priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40, diff --git a/src/utils/login.ts b/src/utils/login.ts deleted file mode 100644 index 8f5d93ffae..0000000000 --- a/src/utils/login.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import Views from "../Views"; - -export function isLoggedIn(): boolean { - // JRS: Maybe we should move the step that writes this to the window out of - // `element-web` and into this file? Better yet, we should probably create a - // store to hold this state. - // See also https://github.com/vector-im/element-web/issues/15034. - return window.matrixChat?.state.view === Views.LOGGED_IN; -} diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index b63896c64d..bdbf86637d 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -329,7 +329,7 @@ describe("DeviceListener", () => { }); it("shows verify session toast when account has cross signing", async () => { - mockCrypto!.userHasCrossSigningKeys.mockResolvedValue(true); + mockCrypto!.isCrossSigningReady.mockResolvedValue(true); await createAndStart(); expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled(); @@ -337,24 +337,25 @@ describe("DeviceListener", () => { SetupEncryptionToast.Kind.VERIFY_THIS_SESSION, ); }); - - it("checks key backup status when when account has cross signing", async () => { - mockCrypto!.getCrossSigningKeyId.mockResolvedValue(null); - mockCrypto!.userHasCrossSigningKeys.mockResolvedValue(true); - await createAndStart(); - - expect(mockCrypto!.getActiveSessionBackupVersion).toHaveBeenCalled(); - }); }); describe("when user does have a cross signing id on this device", () => { beforeEach(() => { + mockCrypto!.isCrossSigningReady.mockResolvedValue(true); mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc"); + mockCrypto!.getDeviceVerificationStatus.mockResolvedValue( + new DeviceVerificationStatus({ + trustCrossSignedDevices: true, + crossSigningVerified: true, + }), + ); }); it("shows set up recovery toast when user has a key backup available", async () => { // non falsy response mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo); + mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null); + await createAndStart(); expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( diff --git a/test/unit-tests/toasts/SetupEncryptionToast-test.tsx b/test/unit-tests/toasts/SetupEncryptionToast-test.tsx index 8917587cc1..22b491817c 100644 --- a/test/unit-tests/toasts/SetupEncryptionToast-test.tsx +++ b/test/unit-tests/toasts/SetupEncryptionToast-test.tsx @@ -16,9 +16,15 @@ describe("SetupEncryptionToast", () => { render(); }); - it("should render the se up recovery toast", async () => { + it("should render the 'set up recovery' toast", async () => { showToast(Kind.SET_UP_RECOVERY); await expect(screen.findByText("Set up recovery")).resolves.toBeInTheDocument(); }); + + it("should render the 'key storage out of sync' toast", async () => { + showToast(Kind.KEY_STORAGE_OUT_OF_SYNC); + + await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument(); + }); }); diff --git a/test/unit-tests/utils/login-test.ts b/test/unit-tests/utils/login-test.ts deleted file mode 100644 index b1f488c29f..0000000000 --- a/test/unit-tests/utils/login-test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import MatrixChat from "../../../src/components/structures/MatrixChat.tsx"; -import { isLoggedIn } from "../../../src/utils/login.ts"; -import Views from "../../../src/Views.ts"; - -describe("isLoggedIn", () => { - it("should return true if MatrixChat state view is LOGGED_IN", () => { - window.matrixChat = { - state: { - view: Views.LOGGED_IN, - }, - } as unknown as MatrixChat; - - expect(isLoggedIn()).toBe(true); - }); -}); From 8884e77ce3c38bf3080810740838e9f6c46e4754 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Fri, 17 Jan 2025 07:13:54 +0100 Subject: [PATCH 4/7] [create-pull-request] automated change (#29015) Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com> --- playwright/testcontainers/synapse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 72715b666b..8841569a10 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -19,7 +19,7 @@ import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverCon import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts"; -const TAG = "develop@sha256:436278578c6b396d3a581f6af020edaff37dd7c3d26d20362de9e05e4a70cee8"; +const TAG = "develop@sha256:e48308d68dec00af6ce43a05785d475de21a37bc2afaabb440d3a575bcc3d57d"; const DEFAULT_CONFIG = { server_name: "localhost", From 7d3041317886934fc58eab77d67fe7540712d7a1 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Fri, 17 Jan 2025 07:18:36 +0100 Subject: [PATCH 5/7] [create-pull-request] automated change (#29016) Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com> --- src/i18n/strings/de_DE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index ef68cf7eab..e59aa51766 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -2379,7 +2379,7 @@ "all_rooms_home_description": "Alle Räume, denen du beigetreten bist, werden auf der Startseite erscheinen.", "always_show_message_timestamps": "Nachrichtenzeitstempel immer anzeigen", "appearance": { - "bundled_emoji_font": "Verwenden Sie den mitgelieferten Emoji Font.", + "bundled_emoji_font": "Mitgelieferte Emoji-Schriftart verwenden", "compact_layout": "Kompakten Text und Nachrichten anzeigen", "compact_layout_description": "Um diese Funktion nutzen zu können, muss das moderne Nachrichtenlayout ausgewählt sein.", "custom_font": "Systemschriftart verwenden", From e42ee727b47ac1c31ea764c5f99b3ff483d3b392 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Jan 2025 11:08:49 +0000 Subject: [PATCH 6/7] Fix more flaky playwright tests (#29007) * Group systemic playwright flakes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix more flaky tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix another flake Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix more flakes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix skip tests being wrong Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../e2e/audio-player/audio-player.spec.ts | 21 +++--- .../e2e/knock/create-knock-room.spec.ts | 1 + playwright/e2e/knock/knock-into-room.spec.ts | 1 + playwright/e2e/messages/messages.spec.ts | 66 ++++++++++++------- .../pinned-messages/pinned-messages.spec.ts | 8 +-- .../e2e/settings/encryption-user-tab/index.ts | 3 +- .../encryption-user-tab/recovery.spec.ts | 4 +- playwright/e2e/spaces/spaces.spec.ts | 4 +- .../spaces/threads-activity-centre/index.ts | 2 + playwright/e2e/timeline/timeline.spec.ts | 1 + playwright/flaky-reporter.ts | 30 +++++++-- playwright/pages/ElementAppPage.ts | 4 -- playwright/pages/client.ts | 16 +---- playwright/services.ts | 8 ++- 14 files changed, 104 insertions(+), 65 deletions(-) diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts index a6d920dcb8..bb766d6b88 100644 --- a/playwright/e2e/audio-player/audio-player.spec.ts +++ b/playwright/e2e/audio-player/audio-player.spec.ts @@ -13,6 +13,14 @@ import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import { ElementAppPage } from "../../pages/ElementAppPage"; +// Find and click "Reply" button +const clickButtonReply = async (tile: Locator) => { + await expect(async () => { + await tile.hover(); + await tile.getByRole("button", { name: "Reply", exact: true }).click(); + }).toPass(); +}; + test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { test.use({ displayName: "Hanako", @@ -222,8 +230,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Find and click "Reply" button on MessageActionBar const tile = page.locator(".mx_EventTile_last"); - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/1sec.ogg"); @@ -251,18 +258,12 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const tile = page.locator(".mx_EventTile_last"); - // Find and click "Reply" button - const clickButtonReply = async () => { - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); - }; - await uploadFile(page, "playwright/sample-files/upload-first.ogg"); // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/upload-second.ogg"); @@ -270,7 +271,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with yet another audio file to create a reply chain await uploadFile(page, "playwright/sample-files/upload-third.ogg"); diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts index 29733481dd..e21b30a3c2 100644 --- a/playwright/e2e/knock/create-knock-room.spec.ts +++ b/playwright/e2e/knock/create-knock-room.spec.ts @@ -81,6 +81,7 @@ test.describe("Create Knock Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); }); }); diff --git a/playwright/e2e/knock/knock-into-room.spec.ts b/playwright/e2e/knock/knock-into-room.spec.ts index 9c2f1ee76b..be6619697d 100644 --- a/playwright/e2e/knock/knock-into-room.spec.ts +++ b/playwright/e2e/knock/knock-into-room.spec.ts @@ -284,6 +284,7 @@ test.describe("Knock Into Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); await spotlightDialog.results.nth(0).click(); diff --git a/playwright/e2e/messages/messages.spec.ts b/playwright/e2e/messages/messages.spec.ts index 03c93d2620..5185be43c9 100644 --- a/playwright/e2e/messages/messages.spec.ts +++ b/playwright/e2e/messages/messages.spec.ts @@ -58,6 +58,16 @@ async function editMessage(page: Page, message: Locator, newMsg: string): Promis await editComposer.press("Enter"); } +const screenshotOptions = (page?: Page) => ({ + mask: page ? [page.locator(".mx_MessageTimestamp")] : undefined, + // Hide the jump to bottom button in the timeline to avoid flakiness + css: ` + .mx_JumpToBottomButton { + display: none !important; + } + `, +}); + test.describe("Message rendering", () => { [ { direction: "ltr", displayName: "Quentin" }, @@ -79,9 +89,10 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "Hello, world!"); - await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }, ); @@ -89,14 +100,17 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays an egg"); - await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`, screenshotOptions()); }); test("should render an LTR rich text emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays a *free range* egg"); - await expect(msgTile).toMatchScreenshot(`emote-rich-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-ltr-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited LTR message", async ({ page, user, app, room }) => { @@ -106,9 +120,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "Hello, universe!"); - await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a LTR message", async ({ page, user, app, room }) => { @@ -122,32 +137,37 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "response to multiline message"); - await expect(msgTile).toMatchScreenshot(`reply-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a basic RTL text message", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render an RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me يضع بيضة"); - await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`, screenshotOptions()); }); test("should render a richtext RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me أضع بيضة *حرة النطاق*"); - await expect(msgTile).toMatchScreenshot(`emote-rich-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-rtl-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited RTL message", async ({ page, user, app, room }) => { @@ -157,9 +177,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "مرحبا بالكون!"); - await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a RTL message", async ({ page, user, app, room }) => { @@ -173,9 +194,10 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`reply-message-trl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-trl-${direction}displayname.png`, + screenshotOptions(page), + ); }); }); }); diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index bb72c02610..de954fb8d4 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -35,10 +35,10 @@ test.describe("Pinned messages", () => { mask: [tile.locator(".mx_MessageTimestamp")], // Hide the jump to bottom button in the timeline to avoid flakiness css: ` - .mx_JumpToBottomButton { - display: none !important; - } - `, + .mx_JumpToBottomButton { + display: none !important; + } + `, }); }, ); diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts index f8adbb2e33..9fc8eecb71 100644 --- a/playwright/e2e/settings/encryption-user-tab/index.ts +++ b/playwright/e2e/settings/encryption-user-tab/index.ts @@ -89,8 +89,7 @@ class Helpers { await expect(dialog.getByText(title, { exact: true })).toBeVisible(); await expect(dialog).toMatchScreenshot(screenshot); - const handle = await this.page.evaluateHandle(() => navigator.clipboard.readText()); - const clipboardContent = await handle.jsonValue(); + const clipboardContent = await this.app.getClipboard(); await dialog.getByRole("textbox").fill(clipboardContent); await dialog.getByRole("button", { name: confirmButtonLabel }).click(); await expect(dialog).toMatchScreenshot("default-recovery.png"); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index a322d42d4e..7ce769059a 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -53,7 +53,7 @@ test.describe("Recovery section in Encryption tab", () => { test( "should change the recovery key", - { tag: "@screenshot" }, + { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, homeserver, credentials, util, context }) => { await verifySession(app, "new passphrase"); const dialog = await util.openEncryptionTab(); @@ -81,7 +81,7 @@ test.describe("Recovery section in Encryption tab", () => { }, ); - test("should setup the recovery key", { tag: "@screenshot" }, async ({ page, app, util }) => { + test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => { await verifySession(app, "new passphrase"); await util.removeSecretStorageDefaultKeyId(); diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 5acb3a672f..37e5606cc1 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -84,7 +84,7 @@ test.describe("Spaces", () => { // Copy matrix.to link await page.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`); // Go to space home await page.getByRole("button", { name: "Go to my first room" }).click(); @@ -177,7 +177,7 @@ test.describe("Spaces", () => { const shareDialog = page.locator(".mx_SpacePublicShare"); // Copy link first await shareDialog.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`); // Start Matrix invite flow await shareDialog.getByRole("button", { name: "Invite people" }).click(); diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index d3d3cb352b..7da6974a92 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -38,11 +38,13 @@ export const test = base.extend<{ room1Name: "Room 1", room1: async ({ room1Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, room2Name: "Room 2", room2: async ({ room2Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, msg: async ({ page, app, util }, use) => { diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 885c15d90f..3de0f7c0f2 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -1195,6 +1195,7 @@ test.describe("Timeline", () => { }); await sendImage(app.client, room.roomId, NEW_AVATAR); + await app.timeline.scrollToBottom(); await expect(page.locator(".mx_MImageBody").first()).toBeVisible(); // Exclude timestamp and read marker from snapshot diff --git a/playwright/flaky-reporter.ts b/playwright/flaky-reporter.ts index 770633decb..f816d7651e 100644 --- a/playwright/flaky-reporter.ts +++ b/playwright/flaky-reporter.ts @@ -24,18 +24,40 @@ type PaginationLinks = { first?: string; }; +// We see quite a few test flakes which are caused by the app exploding +// so we have some magic strings we check the logs for to better track the flake with its cause +const SPECIAL_CASES = { + "ChunkLoadError": "ChunkLoadError", + "Unreachable code should not be executed": "Rust crypto panic", + "Out of bounds memory access": "Rust crypto memory error", +}; + class FlakyReporter implements Reporter { private flakes = new Map(); public onTestEnd(test: TestCase): void { // Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track if (["Dendrite", "Pinecone"].includes(test.parent.project()?.name)) return; - const title = `${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`; + let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`]; if (test.outcome() === "flaky") { - if (!this.flakes.has(title)) { - this.flakes.set(title, []); + const timedOutRuns = test.results.filter((result) => result.status === "timedOut"); + const pageLogs = timedOutRuns.flatMap((result) => + result.attachments.filter((attachment) => attachment.name.startsWith("page-")), + ); + // If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such. + const specialCases = Object.keys(SPECIAL_CASES).filter((log) => + pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body.includes(log)), + ); + if (specialCases.length > 0) { + failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]); + } + + for (const title of failures) { + if (!this.flakes.has(title)) { + this.flakes.set(title, []); + } + this.flakes.get(title).push(test); } - this.flakes.get(title).push(test); } } diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 98d0bf30fb..d530c75b54 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -158,10 +158,6 @@ export class ElementAppPage { return button.click(); } - public async getClipboardText(): Promise { - return this.page.evaluate("navigator.clipboard.readText()"); - } - public async openSpotlight(): Promise { const spotlight = new Spotlight(this.page); await spotlight.open(); diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 362915ce71..611a6cef19 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -15,7 +15,6 @@ import type { ICreateRoomOpts, ISendEventResponse, MatrixClient, - Room, MatrixEvent, ReceiptType, IRoomDirectoryOptions, @@ -178,21 +177,12 @@ export class Client { */ public async createRoom(options: ICreateRoomOpts): Promise { const client = await this.prepareClient(); - return await client.evaluate(async (cli, options) => { + const roomId = await client.evaluate(async (cli, options) => { const { room_id: roomId } = await cli.createRoom(options); - if (!cli.getRoom(roomId)) { - await new Promise((resolve) => { - const onRoom = (room: Room) => { - if (room.roomId === roomId) { - cli.off(window.matrixcs.ClientEvent.Room, onRoom); - resolve(); - } - }; - cli.on(window.matrixcs.ClientEvent.Room, onRoom); - }); - } return roomId; }, options); + await this.awaitRoomMembership(roomId); + return roomId; } /** diff --git a/playwright/services.ts b/playwright/services.ts index c15d63bd02..a501bf6138 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -155,9 +155,13 @@ export const test = base.extend({ { scope: "worker" }, ], - context: async ({ homeserverType, synapseConfig, logger, context, request, homeserver }, use, testInfo) => { + context: async ( + { homeserverType, synapseConfig, logger, context, request, _homeserver, homeserver }, + use, + testInfo, + ) => { testInfo.skip( - !(homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0, + !(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0, `Test specifies Synapse config options so is unsupported with ${homeserverType}`, ); homeserver.setRequest(request); From 72df9c90765edad8ea1ea312d182842e7e5c52b6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:40:10 +0000 Subject: [PATCH 7/7] Update dependency katex to v0.16.21 [SECURITY] (#29022) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1322ab213f..893d93a725 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3529,9 +3529,8 @@ ts-xor "^1.3.0" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" - uid "" "@vector-im/matrix-wysiwyg@2.38.0": version "2.38.0" @@ -8240,9 +8239,9 @@ jwt-decode@4.0.0, jwt-decode@^4.0.0: integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== katex@^0.16.0: - version "0.16.11" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.11.tgz#4bc84d5584f996abece5f01c6ad11304276a33f5" - integrity sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ== + version "0.16.21" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.21.tgz#8f63c659e931b210139691f2cc7bb35166b792a3" + integrity sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A== dependencies: commander "^8.3.0"