diff --git a/src/components/views/beacon/BeaconListItem.tsx b/src/components/views/beacon/BeaconListItem.tsx index ceedbf05fd..b41916efd8 100644 --- a/src/components/views/beacon/BeaconListItem.tsx +++ b/src/components/views/beacon/BeaconListItem.tsx @@ -11,7 +11,6 @@ import { type Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/m import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; -import { humanizeTime } from "../../../utils/humanize"; import { preventDefaultWrapper } from "../../../utils/NativeEventUtils"; import { _t } from "../../../languageHandler"; import MemberAvatar from "../avatars/MemberAvatar"; @@ -19,6 +18,7 @@ import BeaconStatus from "./BeaconStatus"; import { BeaconDisplayStatus } from "./displayStatus"; import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon"; import ShareLatestLocation from "./ShareLatestLocation"; +import { humanizeTime } from "../../../shared-components/utils/humanize"; interface Props { beacon: Beacon; diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index e4f0dfa608..7c2131d4b4 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -35,7 +35,7 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, view_call: true, - skipLobby: "shiftKey" in ev ? ev.shiftKey : false, + skipLobby: ("shiftKey" in ev && ev.shiftKey) || undefined, metricsTrigger: undefined, }); }, diff --git a/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx b/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx index 49e7cad17b..d6a5f79aeb 100644 --- a/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx +++ b/src/components/views/dialogs/ConfirmKeyStorageOffDialog.tsx @@ -17,6 +17,7 @@ import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButt import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "./UserTab"; +import SdkConfig from "../../../SdkConfig"; interface Props { onFinished: (dismissed: boolean) => void; @@ -60,7 +61,7 @@ export default class ConfirmKeyStorageOffDialog extends React.Component { a: (sub) => ( <>
- + {sub} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 73049122dc..bc0ca71d4d 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -24,7 +24,6 @@ import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../../. import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers"; import { abbreviateUrl } from "../../../utils/UrlUtils"; import IdentityAuthClient from "../../../IdentityAuthClient"; -import { humanizeTime } from "../../../utils/humanize"; import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite"; import { Action } from "../../../dispatcher/actions"; import { DefaultTagID } from "../../../stores/room-list/models"; @@ -65,6 +64,8 @@ import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDi import { SdkContextClass } from "../../../contexts/SDKContext"; import { type UserProfilesStore } from "../../../stores/UserProfilesStore"; import InviteProgressBody from "./InviteProgressBody.tsx"; +import { RichList } from "../../../shared-components/rich-list/RichList"; +import { RichItem } from "../../../shared-components/rich-list/RichItem"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -163,7 +164,6 @@ interface IDMRoomTileProps { member: Member; lastActiveTs?: number; onToggle(member: Member): void; - highlightWord: string; isSelected: boolean; } @@ -176,54 +176,8 @@ class DMRoomTile extends React.PureComponent { this.props.onToggle(this.props.member); }; - private highlightName(str: string): ReactNode { - if (!this.props.highlightWord) return str; - - // We convert things to lowercase for index searching, but pull substrings from - // the submitted text to preserve case. Note: we don't need to htmlEntities the - // string because React will safely encode the text for us. - const lowerStr = str.toLowerCase(); - const filterStr = this.props.highlightWord.toLowerCase(); - - const result: JSX.Element[] = []; - - let i = 0; - let ii: number; - while ((ii = lowerStr.indexOf(filterStr, i)) >= 0) { - // Push any text we missed (first bit/middle of text) - if (ii > i) { - // Push any text we aren't highlighting (middle of text match, or beginning of text) - result.push({str.substring(i, ii)}); - } - - i = ii; // copy over ii only if we have a match (to preserve i for end-of-text matching) - - // Highlight the word the user entered - const substr = str.substring(i, filterStr.length + i); - result.push( - - {substr} - , - ); - i += substr.length; - } - - // Push any text we missed (end of text) - if (i < str.length) { - result.push({str.substring(i)}); - } - - return result; - } - public render(): React.ReactNode { - let timestamp: JSX.Element | undefined; - if (this.props.lastActiveTs) { - const humanTs = humanizeTime(this.props.lastActiveTs); - timestamp = {humanTs}; - } - - const avatarSize = "36px"; + const avatarSize = "32px"; const avatar = (this.props.member as ThreepidMember).isEmail ? ( ) : ( @@ -241,40 +195,23 @@ class DMRoomTile extends React.PureComponent { /> ); - let checkmark: JSX.Element | undefined; - if (this.props.isSelected) { - // To reduce flickering we put the 'selected' room tile above the real avatar - checkmark =
; - } - - // To reduce flickering we put the checkmark on top of the actual avatar (prevents - // the browser from reloading the image source when the avatar remounts). - const stackedAvatar = ( - - {avatar} - {checkmark} - - ); - const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, { withDisplayName: true, }); const caption = (this.props.member as ThreepidMember).isEmail ? _t("invite|email_caption") - : this.highlightName(userIdentifier || this.props.member.userId); + : userIdentifier || this.props.member.userId; return ( - - {stackedAvatar} - -
- {this.highlightName(this.props.member.name)} -
-
{caption}
-
- {timestamp} -
+ ); } } @@ -1048,8 +985,13 @@ export default class InviteDialog extends React.PureComponent -

{sectionName}

-

{_t("common|no_results")}

+ + {_t("common|no_results")} +
); } @@ -1084,14 +1026,15 @@ export default class InviteDialog extends React.PureComponent t.userId === r.userId)} /> )); + return (
-

{sectionName}

- {tiles} + + {tiles} + {showMore}
); diff --git a/src/components/views/dialogs/VerificationRequestDialog.tsx b/src/components/views/dialogs/VerificationRequestDialog.tsx index d8cef61036..cb4166b775 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.tsx +++ b/src/components/views/dialogs/VerificationRequestDialog.tsx @@ -60,7 +60,7 @@ export default class VerificationRequestDialog extends React.Component { const label = this.getLabel(); let dateHeaderContent: JSX.Element; - if (this.state.jumpToDateEnabled) { + if (this.state.jumpToDateEnabled && !this.props.forExport) { dateHeaderContent = this.renderJumpToDateMenu(); } else { dateHeaderContent = ( diff --git a/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/src/components/views/rooms/RoomHeader/RoomHeader.tsx index 281df80a29..7e88a31eac 100644 --- a/src/components/views/rooms/RoomHeader/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -129,6 +129,7 @@ export default function RoomHeader({ disabled={!!videoCallDisabledReason} color="primary" aria-label={videoCallDisabledReason ?? _t("action|join")} + data-testId="join-call-button" > {_t("action|join")} diff --git a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx b/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx index a14a39cdc1..86dea71d02 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx @@ -59,6 +59,7 @@ export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX align="center" gap="var(--cpd-space-2x)" wrap="wrap" + className="mx_RoomListPrimaryFilters_list" ref={ref} > {filters.map((filter, i) => ( @@ -103,7 +104,7 @@ function useCollapseFilters( // If the previous element is on the left element of the current one, it means that the filter is wrapping const previousSibling = child.previousElementSibling as HTMLElement | null; - if (previousSibling && child.offsetLeft < previousSibling.offsetLeft) { + if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) { if (!isWrapping) setWrappingIndex(i); isWrapping = true; } diff --git a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 27301110f3..15d6405d87 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -43,6 +43,7 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J element="button" onClick={onClick as (e: ButtonEvent) => void} aria-label={label} + disabled={actionState === "disabled"} className={classNames("mx_FormattingButtons_Button", { mx_FormattingButtons_active: actionState === "reversed", mx_FormattingButtons_Button_hover: actionState === "enabled", @@ -64,55 +65,59 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J interface FormattingButtonsProps { composer: FormattingFunctions; actionStates: AllActionStates; + /** + * Whether all buttons should be disabled + */ + disabled?: boolean; } -export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps): JSX.Element { +export function FormattingButtons({ composer, actionStates, disabled }: FormattingButtonsProps): JSX.Element { const composerContext = useComposerContext(); const isInList = actionStates.unorderedList === "reversed" || actionStates.orderedList === "reversed"; return (
+ ); +}); + +/** + * A checkmark icon inside a circle, used to indicate selection. + */ +function Checkmark(): JSX.Element { + return ( + + ); +} diff --git a/src/shared-components/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap b/src/shared-components/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap new file mode 100644 index 0000000000..7a64249990 --- /dev/null +++ b/src/shared-components/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RichItem renders the item in default state 1`] = ` +
+
    + +
+
+`; + +exports[`RichItem renders the item in selected state 1`] = ` +
+
    + +
+
+`; + +exports[`RichItem renders the item without timestamp 1`] = ` +
+
    + +
+
+`; diff --git a/src/shared-components/rich-list/RichItem/index.ts b/src/shared-components/rich-list/RichItem/index.ts new file mode 100644 index 0000000000..0301144246 --- /dev/null +++ b/src/shared-components/rich-list/RichItem/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { RichItem } from "./RichItem"; diff --git a/src/shared-components/rich-list/RichList/RichList.module.css b/src/shared-components/rich-list/RichList/RichList.module.css new file mode 100644 index 0000000000..9fd59ef103 --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.module.css @@ -0,0 +1,30 @@ +/* + * 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. + */ + +.richList { + height: inherit; +} + +.title { + font: var(--cpd-font-body-sm-semibold); + color: var(--cpd-color-text-secondary); + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x); +} + +.content { + width: 100%; + overflow: auto; + /* remove browser default ul padding/margin */ + padding: 0; + margin: 0; +} + +.empty { + margin-left: var(--cpd-space-6x); + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); +} diff --git a/src/shared-components/rich-list/RichList/RichList.stories.tsx b/src/shared-components/rich-list/RichList/RichList.stories.tsx new file mode 100644 index 0000000000..e4a9406e71 --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.stories.tsx @@ -0,0 +1,50 @@ +/* + * 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 React from "react"; + +import { RichList } from "./RichList"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RichItem } from "../RichItem"; + +const avatar =
; + +const meta = { + title: "RichList/RichList", + component: RichList, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + title: "Rich List Title", + children: ( + <> + + + + + + + ), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Empty: Story = { + args: { + isEmpty: true, + children: "No items available", + }, +}; diff --git a/src/shared-components/rich-list/RichList/RichList.test.tsx b/src/shared-components/rich-list/RichList/RichList.test.tsx new file mode 100644 index 0000000000..625511f68e --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./RichList.stories"; + +const { Default, Empty } = composeStories(stories); + +describe("RichItem", () => { + it("renders the list", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the list with isEmpty=true", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/shared-components/rich-list/RichList/RichList.tsx b/src/shared-components/rich-list/RichList/RichList.tsx new file mode 100644 index 0000000000..a2859f19df --- /dev/null +++ b/src/shared-components/rich-list/RichList/RichList.tsx @@ -0,0 +1,68 @@ +/* + * 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 React, { type HTMLProps, type JSX, type PropsWithChildren } from "react"; +import classNames from "classnames"; + +import styles from "./RichList.module.css"; +import { Flex } from "../../utils/Flex"; + +export interface RichListProps extends HTMLProps { + /** + * Title to display at the top of the list + */ + title: string; + /** + * Attributes to pass to the title element + * This can be used to set accessibility attributes like `aria-level` or `role` + * @example + * ```tsx + * + * ``` + */ + titleAttributes?: HTMLProps; + /** + * Indicates if the list should show an empty state. + * The list renders its children in a span instead of an ul. + */ + isEmpty?: boolean; +} + +/** + * A list component with a title and children. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export function RichList({ + children, + title, + className, + titleAttributes, + isEmpty = false, + ...props +}: PropsWithChildren): JSX.Element { + return ( + + + {title} + + {isEmpty ? ( + {children} + ) : ( +
    + {children} +
+ )} +
+ ); +} diff --git a/src/shared-components/rich-list/RichList/__snapshots__/RichList.test.tsx.snap b/src/shared-components/rich-list/RichList/__snapshots__/RichList.test.tsx.snap new file mode 100644 index 0000000000..529652c080 --- /dev/null +++ b/src/shared-components/rich-list/RichList/__snapshots__/RichList.test.tsx.snap @@ -0,0 +1,186 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RichItem renders the list 1`] = ` +
+
+
+ + Rich List Title + +
    + + + + + +
+
+
+
+`; + +exports[`RichItem renders the list with isEmpty=true 1`] = ` +
+
+
+ + Rich List Title + + + No items available + +
+
+
+`; diff --git a/src/shared-components/rich-list/RichList/index.ts b/src/shared-components/rich-list/RichList/index.ts new file mode 100644 index 0000000000..88999fed3f --- /dev/null +++ b/src/shared-components/rich-list/RichList/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { RichList } from "./RichList"; diff --git a/src/shared-components/utils/humanize.test.ts b/src/shared-components/utils/humanize.test.ts new file mode 100644 index 0000000000..1c07dd3d04 --- /dev/null +++ b/src/shared-components/utils/humanize.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { humanizeTime } from "./humanize"; + +describe("humanizeTime", () => { + const now = new Date("2025-08-01T12:00:00Z").getTime(); + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(now); + }); + + it.each([ + // Past + ["returns 'a few seconds ago' for <15s ago", now - 5000, "a few seconds ago"], + ["returns 'about a minute ago' for <75s ago", now - 60000, "about a minute ago"], + ["returns '20 minutes ago' for <45min ago", now - 20 * 60000, "20 minutes ago"], + ["returns 'about an hour ago' for <75min ago", now - 70 * 60000, "about an hour ago"], + ["returns '5 hours ago' for <23h ago", now - 5 * 3600000, "5 hours ago"], + ["returns 'about a day ago' for <26h ago", now - 25 * 3600000, "about a day ago"], + ["returns '3 days ago' for >26h ago", now - 3 * 24 * 3600000, "3 days ago"], + // Future + ["returns 'a few seconds from now' for <15s ahead", now + 5000, "a few seconds from now"], + ["returns 'about a minute from now' for <75s ahead", now + 60000, "about a minute from now"], + ["returns '20 minutes from now' for <45min ahead", now + 20 * 60000, "20 minutes from now"], + ["returns 'about an hour from now' for <75min ahead", now + 70 * 60000, "about an hour from now"], + ["returns '5 hours from now' for <23h ahead", now + 5 * 3600000, "5 hours from now"], + ["returns 'about a day from now' for <26h ahead", now + 25 * 3600000, "about a day from now"], + ["returns '3 days from now' for >26h ahead", now + 3 * 24 * 3600000, "3 days from now"], + ])("%s", (_, date, expected) => { + expect(humanizeTime(date)).toBe(expected); + }); +}); diff --git a/src/utils/humanize.ts b/src/shared-components/utils/humanize.ts similarity index 98% rename from src/utils/humanize.ts rename to src/shared-components/utils/humanize.ts index 616ee93781..61f7705ace 100644 --- a/src/utils/humanize.ts +++ b/src/shared-components/utils/humanize.ts @@ -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 { _t } from "../languageHandler"; +import { _t } from "./i18n"; // These are the constants we use for when to break the text const MILLISECONDS_RECENT = 15000; diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 6347cc898e..6dc41dfce1 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -7,10 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; -import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; import { type MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; -import type { EmptyObject, GroupCall, Room } from "matrix-js-sdk/src/matrix"; +import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; @@ -53,8 +52,6 @@ export class CallStore extends AsyncStoreWithClient { for (const room of this.matrixClient.getRooms()) { this.updateRoom(room); } - this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); - this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); @@ -85,12 +82,7 @@ export class CallStore extends AsyncStoreWithClient { this.calls.clear(); this._connectedCalls.clear(); - if (this.matrixClient) { - this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); - this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); - this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall); - this.matrixClient.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); - } + this.matrixClient?.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets); } @@ -117,8 +109,16 @@ export class CallStore extends AsyncStoreWithClient { private calls = new Map(); // Key is room ID private callListeners = new Map unknown>>(); + private inUpdateRoom = false; private updateRoom(room: Room): void { - if (!this.calls.has(room.roomId)) { + // XXX: This method is guarded with the flag this.inUpdateRoom because + // we need to block this method from calling itself recursively. That + // could happen, for instance, if Call.get adds a new virtual widget to + // the WidgetStore, firing a WidgetStore update that we don't actually + // care about. Without the guard we could get duplicate Call objects + // fighting for control over the same widget. + if (!this.inUpdateRoom && !this.calls.has(room.roomId)) { + this.inUpdateRoom = true; const call = Call.get(room); if (call) { @@ -149,6 +149,7 @@ export class CallStore extends AsyncStoreWithClient { } this.emit(CallStoreEvent.Call, call, room.roomId); + this.inUpdateRoom = false; } } @@ -186,7 +187,6 @@ export class CallStore extends AsyncStoreWithClient { } }; - private onGroupCall = (groupCall: GroupCall): void => this.updateRoom(groupCall.room); private onRTCSessionStart = (roomId: string, session: MatrixRTCSession): void => { this.updateRoom(session.room); }; diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 50dfd47a07..ee7c2ad9bc 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -109,10 +109,6 @@ interface State { * Whether we're viewing a call or call lobby in this room */ viewingCall: boolean; - /** - * If we want the call to skip the lobby and immediately join - */ - skipLobby?: boolean; promptAskToJoin: boolean; @@ -363,13 +359,13 @@ export class RoomViewStore extends EventEmitter { let call = CallStore.instance.getCall(payload.room_id); // Start a call if not already there if (call === null) { - ElementCall.create(room, false); + ElementCall.create(room); call = CallStore.instance.getCall(payload.room_id)!; } call.presented = true; // Immediately start the call. This will connect to all required widget events // and allow the widget to show the lobby. - if (call.connectionState === ConnectionState.Disconnected) call.start(); + if (call.connectionState === ConnectionState.Disconnected) call.start({ skipLobby: payload.skipLobby }); } // If we switch to a different room from the call, we are no longer presenting it const prevRoomCall = this.state.roomId ? CallStore.instance.getCall(this.state.roomId) : null; @@ -417,7 +413,6 @@ export class RoomViewStore extends EventEmitter { replyingToEvent: null, viaServers: payload.via_servers ?? [], wasContextSwitch: payload.context_switch ?? false, - skipLobby: payload.skipLobby, viewingCall: payload.view_call ?? (payload.room_id === this.state.roomId @@ -474,7 +469,6 @@ export class RoomViewStore extends EventEmitter { viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, viewingCall: payload.view_call ?? false, - skipLobby: payload.skipLobby, }); try { const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(payload.room_alias); @@ -743,10 +737,6 @@ export class RoomViewStore extends EventEmitter { return this.state.viewingCall; } - public skipCallLobby(): boolean | undefined { - return this.state.skipLobby; - } - /** * Gets the current state of the 'promptForAskToJoin' property. * diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 5a76df7103..fbae950dbc 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -189,6 +189,7 @@ export default class WidgetStore extends AsyncStoreWithClient { const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined); this.widgetMap.set(WidgetUtils.getWidgetUid(app), app); this.roomMap.get(roomId)!.widgets.push(app); + this.emit(UPDATE_EVENT, roomId); return app; } @@ -198,6 +199,7 @@ export default class WidgetStore extends AsyncStoreWithClient { if (roomApps) { roomApps.widgets = roomApps.widgets.filter((app) => !(app.id === widgetId && app.roomId === roomId)); } + this.emit(UPDATE_EVENT, roomId); } } diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 30ba79541d..b5f426986e 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.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, { type JSX, useCallback, useEffect, useState } from "react"; +import React, { type JSX, useCallback, useEffect, useRef, useState } from "react"; import { type Room, type MatrixEvent, type RoomMember, RoomEvent, EventType } from "matrix-js-sdk/src/matrix"; import { Button, ToggleInput, Tooltip, TooltipProvider } from "@vector-im/compound-web"; import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; @@ -147,13 +147,18 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element { setConnectedCalls(Array.from(CallStore.instance.connectedCalls)); }); const otherCallIsOngoing = connectedCalls.find((call) => call.roomId !== roomId); - // Start ringing if not already. + const soundHasStarted = useRef(false); useEffect(() => { + // This section can race, so we use a ref to keep track of whether we have started trying to play. + // This is because `LegacyCallHandler.play` tries to load the sound and then play it asynchonously + // and `LegacyCallHandler.isPlaying` will not be `true` until the sound starts playing. const isRingToast = notificationContent.notification_type == "ring"; - if (isRingToast && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) { - LegacyCallHandler.instance.play(AudioID.Ring); + if (isRingToast && !soundHasStarted.current && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) { + // Start ringing if not already. + soundHasStarted.current = true; + void LegacyCallHandler.instance.play(AudioID.Ring); } - }, [notificationContent.notification_type]); + }, [notificationContent.notification_type, soundHasStarted]); // Stop ringing on dismiss. const dismissToast = useCallback((): void => { @@ -237,7 +242,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element { action: Action.ViewRoom, room_id: room?.roomId, view_call: true, - skipLobby: skipLobbyToggle ?? ("shiftKey" in e ? e.shiftKey : false), + skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle, metricsTrigger: undefined, }); }, diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index f4aef8881f..3a202059bc 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -197,7 +197,8 @@ export const showToast = (kind: Kind): void => { deviceListener.dismissEncryptionSetup(); break; } - case Kind.KEY_STORAGE_OUT_OF_SYNC: { + case Kind.KEY_STORAGE_OUT_OF_SYNC: + case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE: { // Open the user settings dialog to the encryption tab and start the flow to reset encryption const payload: OpenToTabPayload = { action: Action.ViewUserSettings, @@ -207,16 +208,6 @@ export const showToast = (kind: Kind): void => { defaultDispatcher.dispatch(payload); break; } - case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE: { - // Open the user settings dialog to the encryption tab and start the flow to reset 4S - const payload: OpenToTabPayload = { - action: Action.ViewUserSettings, - initialTabId: UserTab.Encryption, - props: { initialEncryptionState: "change_recovery_key" }, - }; - defaultDispatcher.dispatch(payload); - break; - } case Kind.TURN_ON_KEY_STORAGE: { // The user clicked "Dismiss": offer them "Are you sure?" const modal = Modal.createDialog( diff --git a/src/utils/room/placeCall.ts b/src/utils/room/placeCall.ts index 1f0d67c1e6..590ded7a80 100644 --- a/src/utils/room/placeCall.ts +++ b/src/utils/room/placeCall.ts @@ -21,12 +21,13 @@ import PosthogTrackers from "../../PosthogTrackers"; * @param room the room to place the call in * @param callType the type of call * @param platformCallType the platform to pass the call on + * @param skipLobby Has the user indicated they would like to skip the lobby. Otherwise, defer to platform defaults. */ export const placeCall = async ( room: Room, callType: CallType, platformCallType: PlatformCallType, - skipLobby: boolean, + skipLobby?: boolean, ): Promise => { const { analyticsName } = getPlatformCallTypeProps(platformCallType); PosthogTrackers.trackInteraction(analyticsName); diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx index 4139f73d53..236703922c 100644 --- a/src/vector/platform/ElectronPlatform.tsx +++ b/src/vector/platform/ElectronPlatform.tsx @@ -558,4 +558,12 @@ export default class ElectronPlatform extends BasePlatform { } return url; } + + public checkSessionLockFree(): boolean { + return true; + } + + public async getSessionLock(_onNewInstance: () => Promise): Promise { + return true; + } } diff --git a/src/vector/platform/WebPlatform.ts b/src/vector/platform/WebPlatform.ts index 46b6a61f83..87ed2389dc 100644 --- a/src/vector/platform/WebPlatform.ts +++ b/src/vector/platform/WebPlatform.ts @@ -22,6 +22,7 @@ import ToastStore from "../../stores/ToastStore.ts"; import GenericToast from "../../components/views/toasts/GenericToast.tsx"; import SdkConfig from "../../SdkConfig.ts"; import type { ActionPayload } from "../../dispatcher/payloads.ts"; +import * as SessionLock from "../../utils/SessionLock.ts"; const POKE_RATE_MS = 10 * 60 * 1000; // 10 min @@ -268,4 +269,12 @@ export default class WebPlatform extends BasePlatform { public reload(): void { window.location.reload(); } + + public checkSessionLockFree(): boolean { + return SessionLock.checkSessionLockFree(); + } + + public async getSessionLock(onNewInstance: () => Promise): Promise { + return SessionLock.getSessionLock(onNewInstance); + } } diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index 36fc2b505f..c122d1d756 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -7,11 +7,29 @@ Please see LICENSE files in the repository root for full details. */ import { MatrixWidgetType } from "matrix-widget-api"; +import { + type GroupCall, + Room, + type RoomMember, + type MatrixEvent, + type MatrixClient, + PendingEventOrdering, + KnownMembership, + RoomStateEvent, + type IContent, +} from "matrix-js-sdk/src/matrix"; +import { mocked, type Mocked } from "jest-mock"; +import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; -import type { GroupCall, Room, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { mkEvent } from "./test-utils"; +import { mkEvent, mkRoomMember, setupAsyncStoreWithClient, stubClient } from "./test-utils"; import { Call, type ConnectionState, ElementCall, JitsiCall } from "../../src/models/Call"; import { CallStore } from "../../src/stores/CallStore"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { MockEventEmitter } from "./client"; +import WidgetStore from "../../src/stores/WidgetStore"; +import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore"; +import SettingsStore from "../../src/settings/SettingsStore"; export class MockedCall extends Call { public static readonly EVENT_TYPE = "org.example.mocked_call"; @@ -105,8 +123,92 @@ export class MockedCall extends Call { /** * Sets up the call store to use mocked calls. */ -export const useMockedCalls = () => { +export function useMockedCalls() { Call.get = (room) => MockedCall.get(room); JitsiCall.create = async (room) => MockedCall.create(room, "1"); ElementCall.create = (room) => MockedCall.create(room, "1"); -}; +} + +/** + * Enables the feature flags required for call tests. + */ +export function enableCalls(): { enabledSettings: Set } { + const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]); + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName): any => { + if (settingName.startsWith("feature_")) return enabledSettings.has(settingName); + if (settingName === "activeCallRoomIds") return []; + return undefined; + }); + return { enabledSettings }; +} + +export function setUpClientRoomAndStores(): { + client: Mocked; + room: Room; + alice: RoomMember; + bob: RoomMember; + carol: RoomMember; + roomSession: Mocked; +} { + stubClient(); + const client = mocked(MatrixClientPeg.safeGet()); + DMRoomMap.makeShared(client); + + const room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const alice = mkRoomMember(room.roomId, "@alice:example.org"); + const bob = mkRoomMember(room.roomId, "@bob:example.org"); + const carol = mkRoomMember(room.roomId, "@carol:example.org"); + jest.spyOn(room, "getMember").mockImplementation((userId) => { + switch (userId) { + case alice.userId: + return alice; + case bob.userId: + return bob; + case carol.userId: + return carol; + default: + return null; + } + }); + + jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); + + client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); + + const roomSession = new MockEventEmitter({ + memberships: [], + getOldestMembership: jest.fn().mockReturnValue(undefined), + room, + }) as Mocked; + + client.matrixRTC.getRoomSession.mockReturnValue(roomSession); + client.getRooms.mockReturnValue([room]); + client.getUserId.mockReturnValue(alice.userId); + client.getDeviceId.mockReturnValue("alices_device"); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { + if (roomId !== room.roomId) throw new Error("Unknown room"); + const event = mkEvent({ + event: true, + type: eventType, + room: roomId, + user: alice.userId, + skey: stateKey, + content: content as IContent, + }); + room.addLiveEvents([event], { addToState: true }); + return { event_id: event.getId()! }; + }); + + setupAsyncStoreWithClient(WidgetStore.instance, client); + setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); + + return { client, room, alice, bob, carol, roomSession }; +} + +export function cleanUpClientRoomAndStores(client: MatrixClient, room: Room) { + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); +} diff --git a/test/test-utils/platform.ts b/test/test-utils/platform.ts index 3769826576..ec742a407e 100644 --- a/test/test-utils/platform.ts +++ b/test/test-utils/platform.ts @@ -10,6 +10,7 @@ import { type MethodLikeKeys, mocked, type MockedObject } from "jest-mock"; import BasePlatform from "../../src/BasePlatform"; import PlatformPeg from "../../src/PlatformPeg"; +import * as SessionLock from "../../src/utils/SessionLock"; // doesn't implement abstract // @ts-ignore @@ -18,6 +19,14 @@ class MockPlatform extends BasePlatform { super(); Object.assign(this, platformMocks); } + + public checkSessionLockFree(): boolean { + return SessionLock.checkSessionLockFree(); + } + + public async getSessionLock(onNewInstance: () => Promise): Promise { + return SessionLock.getSessionLock(onNewInstance); + } } /** * Mock Platform Peg diff --git a/test/unit-tests/SupportedBrowser-test.ts b/test/unit-tests/SupportedBrowser-test.ts index 0eb2b42ed6..270da1baa0 100644 --- a/test/unit-tests/SupportedBrowser-test.ts +++ b/test/unit-tests/SupportedBrowser-test.ts @@ -64,19 +64,19 @@ describe("SupportedBrowser", () => { // Grabbed from https://www.whatismybrowser.com/guides/the-latest-user-agent/ it.each([ // Safari 18.0 on macOS Sonoma - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15", // Latest Firefox on macOS Sonoma - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:139.0) Gecko/20100101 Firefox/139.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:143.0) Gecko/20100101 Firefox/143.0", // Latest Edge on Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.3240.92", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", // Latest Edge on macOS - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.3240.92", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.3485.66", // Latest Firefox on Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0", // Latest Firefox on Linux - "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0", + "Mozilla/5.0 (X11; Linux i686; rv:143.0) Gecko/20100101 Firefox/143.0", // Latest Chrome on Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", ])("should not warn for supported browsers", testUserAgentFactory()); it.each([ diff --git a/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap b/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap index fdffb74ac3..472feb6dcf 100644 --- a/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap +++ b/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`SlashCommands /lenny should match snapshot with args 1`] = ` { diff --git a/test/unit-tests/__snapshots__/TextForEvent-test.ts.snap b/test/unit-tests/__snapshots__/TextForEvent-test.ts.snap index 6713c3d449..ac063c3432 100644 --- a/test/unit-tests/__snapshots__/TextForEvent-test.ts.snap +++ b/test/unit-tests/__snapshots__/TextForEvent-test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`TextForEvent textForJoinRulesEvent() returns correct JSX message when room join rule changed to restricted 1`] = ` diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index e16e42d0d4..eef9b999d2 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -1632,6 +1632,10 @@ describe("", () => { }); describe("Multi-tab lockout", () => { + beforeEach(() => { + mockPlatformPeg(); + }); + afterEach(() => { Lifecycle.setSessionLockNotStolen(); }); @@ -1677,6 +1681,8 @@ describe("", () => { beforeEach(() => { // make sure we start from a clean DOM for each of these tests document.body.replaceChildren(); + // use the MockPlatform + mockPlatformPeg(); }); function simulateSessionLockClaim() { diff --git a/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap index 6c898840c1..2aa9d6e4a7 100644 --- a/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[` Multi-tab lockout shows the lockout page when a second tab opens after a session is restored 1`] = `
diff --git a/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap index bb748e820a..c2728d737d 100644 --- a/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`MessagePanel should handle large numbers of hidden events quickly 1`] = ` diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 401355f5ea..c36a22d066 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `
diff --git a/test/unit-tests/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap index 0a64ec5f61..c75b79aee4 100644 --- a/test/unit-tests/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`SpaceHierarchy renders 1`] = ` diff --git a/test/unit-tests/components/structures/__snapshots__/ThreadPanel-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/ThreadPanel-test.tsx.snap index aa5600052b..e30c3e5618 100644 --- a/test/unit-tests/components/structures/__snapshots__/ThreadPanel-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/ThreadPanel-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properly renders Show: All threads 1`] = ` diff --git a/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap b/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap index 7c225cb014..564fb0ba8b 100644 --- a/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap +++ b/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[` renders a fallback when there are no locations 1`] = `
when user has live location monitor renders correctly when minimized 1`] = ` diff --git a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx index 2d7669a5dc..4341095614 100644 --- a/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx @@ -398,9 +398,7 @@ describe("InviteDialog", () => { input.focus(); await userEvent.keyboard(`${aliceId}`); - const btn = await screen.findByText(aliceId, { - selector: ".mx_InviteDialog_tile_nameStack_userId .mx_InviteDialog_tile--room_highlight", - }); + const btn = await screen.findByRole("option", { name: aliceId }); fireEvent.click(btn); const tile = await screen.findByText(aliceId, { selector: ".mx_InviteDialog_userTile_name" }); diff --git a/test/unit-tests/components/views/dialogs/VerificationRequestDialog-test.tsx b/test/unit-tests/components/views/dialogs/VerificationRequestDialog-test.tsx new file mode 100644 index 0000000000..e585f8a065 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/VerificationRequestDialog-test.tsx @@ -0,0 +1,228 @@ +/* +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 React from "react"; +import { act, render, screen } from "jest-matrix-react"; +import { User } from "matrix-js-sdk/src/matrix"; +import { + type ShowSasCallbacks, + VerificationPhase, + type Verifier, + type VerificationRequest, + type ShowQrCodeCallbacks, +} from "matrix-js-sdk/src/crypto-api"; +import { VerificationMethod } from "matrix-js-sdk/src/types"; + +import VerificationRequestDialog from "../../../../../src/components/views/dialogs/VerificationRequestDialog"; +import { stubClient } from "../../../../test-utils"; + +describe("VerificationRequestDialog", () => { + function renderComponent(phase: VerificationPhase, method?: "emoji" | "qr"): ReturnType { + const member = User.createUser("@alice:example.org", stubClient()); + const request = createRequest(phase, method); + + return render( + , + ); + } + + it("Initially, asks how you would like to verify this device", async () => { + const dialog = renderComponent(VerificationPhase.Ready); + + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + expect(screen.getByText("Verify this device by completing one of the following:")).toBeInTheDocument(); + + expect(dialog.asFragment()).toMatchSnapshot(); + }); + + it("After we started verification here, says we are waiting for the other device", async () => { + const dialog = renderComponent(VerificationPhase.Requested); + + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + + expect( + screen.getByText("To proceed, please accept the verification request on your other device."), + ).toBeInTheDocument(); + + expect(dialog.asFragment()).toMatchSnapshot(); + }); + + it("When other device accepted emoji, displays emojis and asks for confirmation", async () => { + const dialog = renderComponent(VerificationPhase.Started, "emoji"); + + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + + expect( + screen.getByText("Confirm the emoji below are displayed on both devices, in the same order:"), + ).toBeInTheDocument(); + + expect(dialog.asFragment()).toMatchSnapshot(); + }); + + it("After scanning QR, shows confirmation dialog", async () => { + const dialog = renderComponent(VerificationPhase.Started, "qr"); + + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Verify by scanning" })).toBeInTheDocument(); + + expect(screen.getByText("Almost there! Is your other device showing the same shield?")).toBeInTheDocument(); + + expect(dialog.asFragment()).toMatchSnapshot(); + }); + + it("Shows a successful message if verification finished normally", async () => { + const dialog = renderComponent(VerificationPhase.Done); + + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + expect(screen.getByText("You've successfully verified your device!")).toBeInTheDocument(); + + expect(dialog.asFragment()).toMatchSnapshot(); + }); + + it("Shows a failure message if verification was cancelled", async () => { + const dialog = renderComponent(VerificationPhase.Cancelled); + + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Verification cancelled" })).toBeInTheDocument(); + + expect( + screen.getByText( + "You cancelled verification on your other device. Start verification again from the notification.", + ), + ).toBeInTheDocument(); + + expect(dialog.asFragment()).toMatchSnapshot(); + }); + + it("Renders correctly if the request is supplied later via a promise", async () => { + // Given we supply a promise of a request instead of a request + const member = User.createUser("@alice:example.org", stubClient()); + const requestPromise = Promise.resolve(createRequest(VerificationPhase.Cancelled)); + + // When we render the dialog + render( + , + ); + + // And wait for the component to mount, the promise to resolve and the component state to update + await act(async () => await new Promise(process.nextTick)); + + // Then it renders the resolved information + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Verification cancelled" })).toBeInTheDocument(); + + expect( + screen.getByText( + "You cancelled verification on your other device. Start verification again from the notification.", + ), + ).toBeInTheDocument(); + }); + + it("Renders the later promise request if both immediate and promise are supplied", async () => { + // Given we supply a promise of a request as well as a request + const member = User.createUser("@alice:example.org", stubClient()); + const request = createRequest(VerificationPhase.Ready); + const requestPromise = Promise.resolve(createRequest(VerificationPhase.Cancelled)); + + // When we render the dialog + render( + , + ); + + // And wait for the component to mount, the promise to resolve and the component state to update + await act(async () => await new Promise(process.nextTick)); + + // Then it renders the information from the request in the promise + expect(screen.getByRole("heading", { name: "Verify other device" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Verification cancelled" })).toBeInTheDocument(); + + expect( + screen.getByText( + "You cancelled verification on your other device. Start verification again from the notification.", + ), + ).toBeInTheDocument(); + }); +}); + +function createRequest(phase: VerificationPhase, method?: "emoji" | "qr"): VerificationRequest { + let verifier = undefined; + let chosenMethod = undefined; + + switch (method) { + case "emoji": + chosenMethod = VerificationMethod.Sas; + verifier = createEmojiVerifier(); + break; + case "qr": + chosenMethod = VerificationMethod.Reciprocate; + verifier = createQrVerifier(); + break; + } + + return { + phase: jest.fn().mockReturnValue(phase), + + // VerificationRequest is an emitter - ignore any events that are emitted. + on: jest.fn(), + off: jest.fn(), + + // These tests (so far) only check for when we are initiating a verificiation of our own device. + isSelfVerification: jest.fn().mockReturnValue(true), + initiatedByMe: jest.fn().mockReturnValue(true), + + // Always returning true means we can support QR code and emoji verification. + otherPartySupportsMethod: jest.fn().mockReturnValue(true), + + // If we asked for emoji, these are populated. + verifier, + chosenMethod, + } as unknown as VerificationRequest; +} + +function createEmojiVerifier(): Verifier { + const showSasCallbacks = { + sas: { + emoji: [ + // Example set of emoji to display. + ["🐶", "Dog"], + ["🐱", "Cat"], + ], + }, + } as ShowSasCallbacks; + + return { + getShowSasCallbacks: jest.fn().mockReturnValue(showSasCallbacks), + getReciprocateQrCodeCallbacks: jest.fn(), + on: jest.fn(), + off: jest.fn(), + verify: jest.fn(), + } as unknown as Verifier; +} + +function createQrVerifier(): Verifier { + const reciprocateQrCodeCallbacks = { + confirm: jest.fn(), + cancel: jest.fn(), + } as ShowQrCodeCallbacks; + + return { + getShowSasCallbacks: jest.fn(), + getReciprocateQrCodeCallbacks: jest.fn().mockReturnValue(reciprocateQrCodeCallbacks), + on: jest.fn(), + off: jest.fn(), + verify: jest.fn(), + } as unknown as Verifier; +} diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap index 9e0def2569..edd956a05d 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmKeyStorageOffDialog-test.tsx.snap @@ -34,7 +34,7 @@ exports[`ConfirmKeyStorageOffDialog renders 1`] = ` If you sign out of all your devices you will lose your message history and will need to verify all your existing contacts again.
diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap index a99158eadf..d4789c9b93 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[` for a private room should create a private room 1`] = ` diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap index f2d22a98ef..b873347666 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[` renders export dialog 1`] = `
renders tabs correctly 1`] = ` NodeList [ diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/VerificationRequestDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/VerificationRequestDialog-test.tsx.snap new file mode 100644 index 0000000000..f0336ef559 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/__snapshots__/VerificationRequestDialog-test.tsx.snap @@ -0,0 +1,440 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VerificationRequestDialog After scanning QR, shows confirmation dialog 1`] = ` + +
+