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/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 87bf84d8c4..8067cc065c 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -21,12 +21,11 @@ interface JoinCallViewProps { room: Room; resizing: boolean; call: Call; - skipLobby?: boolean; role?: AriaRole; onClose: () => void; } -const JoinCallView: FC = ({ room, resizing, call, skipLobby, role, onClose }) => { +const JoinCallView: FC = ({ room, resizing, call, role, onClose }) => { const cli = useContext(MatrixClientContext); useTypedEventEmitter(call, CallEvent.Close, onClose); @@ -35,12 +34,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, call.clean(); }, [call]); - useEffect(() => { - // Always update the widget data so that we don't ignore "skipLobby" accidentally. - call.widget.data ??= {}; - call.widget.data.skipLobby = skipLobby; - }, [call.widget, skipLobby]); - const disconnectAllOtherCalls: () => Promise = useCallback(async () => { // The stickyPromise has to resolve before the widget actually becomes sticky. // We only let the widget become sticky after disconnecting all other active calls. @@ -69,7 +62,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, interface CallViewProps { room: Room; resizing: boolean; - skipLobby?: boolean; role?: AriaRole; /** * Callback for when the user closes the call. @@ -77,19 +69,8 @@ interface CallViewProps { onClose: () => void; } -export const CallView: FC = ({ room, resizing, skipLobby, role, onClose }) => { +export const CallView: FC = ({ room, resizing, role, onClose }) => { const call = useCall(room.roomId); - return ( - call && ( - - ) - ); + return call && ; }; diff --git a/src/hooks/room/useRoomCall.tsx b/src/hooks/room/useRoomCall.tsx index 5e7b48ee54..39e7725659 100644 --- a/src/hooks/room/useRoomCall.tsx +++ b/src/hooks/room/useRoomCall.tsx @@ -229,7 +229,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey ?? false); + placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined); } }, [promptPinWidget, room, widget], @@ -240,7 +240,9 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey ?? false); + // If we have pressed shift then always skip the lobby, otherwise `undefined` will defer + // to the defaults of the call implementation. + placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined); } }, [widget, promptPinWidget, room], diff --git a/src/models/Call.ts b/src/models/Call.ts index 1eabd09698..356aef1f3b 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -199,7 +199,7 @@ export abstract class Call extends TypedEventEmitter { + public async start(_params?: WidgetGenerationParameters): Promise { const messagingStore = WidgetMessagingStore.instance; this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null; if (!this.messaging) { @@ -547,6 +547,17 @@ export enum ElementCallIntent { JoinExistingDM = "join_existing_dm", } +/** + * Parameters to be passed during widget creation. + * These parameters are hints only, and may not be accepted by the implementation. + */ +export interface WidgetGenerationParameters { + /** + * Skip showing the lobby screen of a call. + */ + skipLobby?: boolean; +} + /** * A group call using MSC3401 and Element Call as a backend. * (somewhat cheekily named) @@ -565,21 +576,112 @@ export class ElementCall extends Call { this.checkDestroy(); } - private static generateWidgetUrl(client: MatrixClient, roomId: string): URL { - const baseUrl = window.location.href; - let url = new URL("./widgets/element-call/index.html#", baseUrl); // this strips hash fragment from baseUrl + public widgetGenerationParameters: WidgetGenerationParameters = {}; - const elementCallUrl = SettingsStore.getValue("Developer.elementCallUrl"); - if (elementCallUrl) url = new URL(elementCallUrl); + /** + * Calculate the correct intent (and associated parameters) for an Element Call room. Paarameters + * will be applied to the `params` instance. + * + * @param params Existing URL parameters + * @param client The current client. + * @param roomId The room ID for the call. + */ + private static appendRoomParams(params: URLSearchParams, client: MatrixClient, roomId: string): void { + const room = client.getRoom(roomId); + if (!room) { + // If the room isn't known, or the room is a video room then skip setting an intent. + return; + } else if (isVideoRoom(room)) { + // Video rooms already exist, so just treat as if we're joining a group call. + params.append("intent", ElementCallIntent.JoinExisting); + // Video rooms should always return to lobby. + params.append("returnToLobby", "true"); + // Never skip the lobby, we always want to give the caller a chance to explicitly join. + params.append("skipLobby", "false"); + // Never preload, as per below warning. + params.append("preload", "false"); + return; + } + const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); + const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership(); + const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId(); + // XXX: @element-hq/element-call-embedded <= 0.15.0 sets the wrong parameter for + // preload by default so we override here. This can be removed when that package + // is released and upgraded. + if (isDM) { + if (hasCallStarted) { + params.append("intent", ElementCallIntent.JoinExistingDM); + params.append("preload", "false"); + } else { + params.append("intent", ElementCallIntent.StartCallDM); + params.append("preload", "false"); + } + } else { + if (hasCallStarted) { + params.append("intent", ElementCallIntent.JoinExisting); + params.append("preload", "false"); + } else { + params.append("intent", ElementCallIntent.StartCall); + params.append("preload", "false"); + } + } + } + + /** + * Calculate the correct analytics parameters for an Element Call room. Paarameters + * will be applied to the `params` instance. + * + * @param params Existing URL parameters + * @param client The current client. + */ + private static appendAnalyticsParams(params: URLSearchParams, client: MatrixClient): void { + const posthogConfig = SdkConfig.get("posthog"); + if (!posthogConfig || PosthogAnalytics.instance.getAnonymity() === Anonymity.Disabled) { + return; + } + + const accountAnalyticsData = client.getAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE)?.getContent(); + // The analyticsID is passed directly to element call (EC) since this codepath is only for EC and no other widget. + // We really don't want the same analyticID's for the EC and EW posthog instances (Data on posthog should be limited/anonymized as much as possible). + // This is prohibited in EC where a hashed version of the analyticsID is used for the actual posthog identification. + // We can pass the raw EW analyticsID here since we need to trust EC with not sending sensitive data to posthog (EC has access to more sensible data than the analyticsID e.g. the username) + const analyticsID: string = accountAnalyticsData?.pseudonymousAnalyticsOptIn ? accountAnalyticsData?.id : ""; + + params.append("analyticsID", analyticsID); // Legacy, deprecated in favour of posthogUserId + params.append("posthogUserId", analyticsID); + params.append("posthogApiHost", posthogConfig.api_host); + params.append("posthogApiKey", posthogConfig.project_api_key); + + // We gate passing sentry behind analytics consent as EC shares data automatically without user-consent, + // unlike EW where data is shared upon an intentional user action (rageshake). + const sentryConfig = SdkConfig.get("sentry"); + if (sentryConfig) { + params.append("sentryDsn", sentryConfig.dsn); + params.append("sentryEnvironment", sentryConfig.environment ?? ""); + } + } + + /** + * Generate the correct Element Call widget URL for creating or joining a call in this room. + * Unless `Developer.elementCallUrl` is set, the widget will use the embedded Element Call package. + * + * @param client + * @param roomId + * @param opts + * @returns + */ + private static generateWidgetUrl(client: MatrixClient, roomId: string, opts: WidgetGenerationParameters = {}): URL { + const elementCallUrlOverride = SettingsStore.getValue("Developer.elementCallUrl"); + const url = elementCallUrlOverride + ? new URL(elementCallUrlOverride) + : // this strips hash fragment from baseUrl + new URL("./widgets/element-call/index.html#", window.location.href); // Splice together the Element Call URL for this call + // Parameters can be found in https://github.com/element-hq/element-call/blob/livekit/src/UrlParams.ts. const params = new URLSearchParams({ - confineToRoom: "true", // Only show the call interface for the configured room // Template variables are used, so that this can be configured using the widget data. - skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own. - returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms) perParticipantE2EE: "$perParticipantE2EE", - header: "none", // Hide the header since our room header is enough userId: client.getUserId()!, deviceId: client.getDeviceId()!, roomId: roomId, @@ -589,33 +691,8 @@ export class ElementCall extends Call { theme: "$org.matrix.msc2873.client_theme", }); - const room = client.getRoom(roomId); - if (room !== null && !isVideoRoom(room)) { - const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); - const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership(); - const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId(); - // XXX: @element-hq/element-call-embedded <= 0.15.0 sets the wrong parameter for - // preload by default so we override here. This can be removed when that package - // is released and upgraded. - if (isDM) { - params.append("sendNotificationType", "ring"); - if (hasCallStarted) { - params.append("intent", ElementCallIntent.JoinExistingDM); - params.append("preload", "false"); - } else { - params.append("intent", ElementCallIntent.StartCallDM); - params.append("preload", "false"); - } - } else { - params.append("sendNotificationType", "notification"); - if (hasCallStarted) { - params.append("intent", ElementCallIntent.JoinExisting); - params.append("preload", "false"); - } else { - params.append("intent", ElementCallIntent.StartCall); - params.append("preload", "false"); - } - } + if (typeof opts.skipLobby === "boolean") { + params.set("skipLobby", opts.skipLobby.toString()); } const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url"); @@ -623,34 +700,10 @@ export class ElementCall extends Call { params.append("rageshakeSubmitUrl", rageshakeSubmitUrl); } - const posthogConfig = SdkConfig.get("posthog"); - if (posthogConfig && PosthogAnalytics.instance.getAnonymity() !== Anonymity.Disabled) { - const accountAnalyticsData = client.getAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE)?.getContent(); - // The analyticsID is passed directly to element call (EC) since this codepath is only for EC and no other widget. - // We really don't want the same analyticID's for the EC and EW posthog instances (Data on posthog should be limited/anonymized as much as possible). - // This is prohibited in EC where a hashed version of the analyticsID is used for the actual posthog identification. - // We can pass the raw EW analyticsID here since we need to trust EC with not sending sensitive data to posthog (EC has access to more sensible data than the analyticsID e.g. the username) - const analyticsID: string = accountAnalyticsData?.pseudonymousAnalyticsOptIn - ? accountAnalyticsData?.id - : ""; - - params.append("analyticsID", analyticsID); // Legacy, deprecated in favour of posthogUserId - params.append("posthogUserId", analyticsID); - params.append("posthogApiHost", posthogConfig.api_host); - params.append("posthogApiKey", posthogConfig.project_api_key); - - // We gate passing sentry behind analytics consent as EC shares data automatically without user-consent, - // unlike EW where data is shared upon an intentional user action (rageshake). - const sentryConfig = SdkConfig.get("sentry"); - if (sentryConfig) { - params.append("sentryDsn", sentryConfig.dsn); - params.append("sentryEnvironment", sentryConfig.environment ?? ""); - } - } - if (SettingsStore.getValue("fallbackICEServerAllowed")) { params.append("allowIceFallback", "true"); } + if (SettingsStore.getValue("feature_allow_screen_share_only_mode")) { params.append("allowVoipWithNoMedia", "true"); } @@ -667,6 +720,8 @@ export class ElementCall extends Call { }) .forEach((font) => params.append("font", font)); } + this.appendAnalyticsParams(params, client); + this.appendRoomParams(params, client, roomId); const replacedUrl = params.toString().replace(/%24/g, "$"); url.hash = `#?${replacedUrl}`; @@ -674,27 +729,12 @@ export class ElementCall extends Call { } // Creates a new widget if there isn't any widget of typ Call in this room. - // Defaults for creating a new widget are: skipLobby = false - // When there is already a widget the current widget configuration will be used or can be overwritten - // by passing the according parameters (skipLobby). - private static createOrGetCallWidget( - roomId: string, - client: MatrixClient, - skipLobby: boolean | undefined, - returnToLobby: boolean | undefined, - ): IApp { + private static createOrGetCallWidget(roomId: string, client: MatrixClient): IApp { const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type)); if (ecWidget) { // Always update the widget data because even if the widget is already created, // we might have settings changes that update the widget. - const overwrites: IWidgetData = {}; - if (skipLobby !== undefined) { - overwrites.skipLobby = skipLobby; - } - if (returnToLobby !== undefined) { - overwrites.returnToLobby = returnToLobby; - } - ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}, overwrites); + ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}, {}); return ecWidget; } @@ -709,15 +749,7 @@ export class ElementCall extends Call { type: WidgetType.CALL.preferred, url: url.toString(), waitForIframeLoad: false, - data: ElementCall.getWidgetData( - client, - roomId, - {}, - { - skipLobby: skipLobby ?? false, - returnToLobby: returnToLobby ?? false, - }, - ), + data: ElementCall.getWidgetData(client, roomId, {}, {}), }, roomId, ); @@ -774,23 +806,26 @@ export class ElementCall extends Call { // - or this is a call room. Then we also always want to show a call. if (hasEcWidget || session.memberships.length !== 0 || room.isCallRoom()) { // create a widget for the case we are joining a running call and don't have on yet. - const availableOrCreatedWidget = ElementCall.createOrGetCallWidget( - room.roomId, - room.client, - undefined, - isVideoRoom(room), - ); + const availableOrCreatedWidget = ElementCall.createOrGetCallWidget(room.roomId, room.client); return new ElementCall(session, availableOrCreatedWidget, room.client); } return null; } - public static create(room: Room, skipLobby = false): void { - ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room)); + public static create(room: Room): void { + ElementCall.createOrGetCallWidget(room.roomId, room.client); } - public async start(): Promise { + public async start(widgetGenerationParameters: WidgetGenerationParameters): Promise { + // Some parameters may only be set once the user has chosen to interact with the call, regenerate the URL + // at this point in case any of the parameters have changed. + this.widgetGenerationParameters = { ...this.widgetGenerationParameters, ...widgetGenerationParameters }; + this.widget.url = ElementCall.generateWidgetUrl( + this.client, + this.roomId, + this.widgetGenerationParameters, + ).toString(); await super.start(); this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 50ca69eec3..418a7a7d4f 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; @@ -359,13 +355,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; @@ -413,7 +409,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 @@ -470,7 +465,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); @@ -739,10 +733,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/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 148bd8504f..b5f426986e 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -242,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/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/test/unit-tests/components/views/voip/CallView-test.tsx b/test/unit-tests/components/views/voip/CallView-test.tsx index ca6a50b418..2b971a0252 100644 --- a/test/unit-tests/components/views/voip/CallView-test.tsx +++ b/test/unit-tests/components/views/voip/CallView-test.tsx @@ -82,13 +82,13 @@ describe("CallView", () => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }); - const renderView = async (skipLobby = false, role: string | undefined = undefined): Promise => { - render( {}} />); + const renderView = async (role: string | undefined = undefined): Promise => { + render( {}} />); await act(() => Promise.resolve()); // Let effects settle }; it("accepts an accessibility role", async () => { - await renderView(undefined, "main"); + await renderView("main"); screen.getByRole("main"); }); @@ -97,9 +97,4 @@ describe("CallView", () => { await renderView(); expect(cleanSpy).toHaveBeenCalled(); }); - - it("updates the call's skipLobby parameter", async () => { - await renderView(true); - expect(call.widget.data?.skipLobby).toBe(true); - }); }); diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index d25d477395..5d83aa335d 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -679,7 +679,7 @@ describe("ElementCall", () => { expect(urlParams.get("analyticsID")).toBeFalsy(); }); - it("requests ringing notifications and correct intent in DMs", async () => { + it("requests correct intent in DMs", async () => { getUserIdForRoomIdSpy.mockImplementation((roomId: string) => room.roomId === roomId ? "any-user" : undefined, ); @@ -688,7 +688,6 @@ describe("ElementCall", () => { if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); - expect(urlParams.get("sendNotificationType")).toBe("ring"); expect(urlParams.get("intent")).toBe(ElementCallIntent.StartCallDM); }); @@ -724,15 +723,6 @@ describe("ElementCall", () => { const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); expect(urlParams.get("intent")).toBe(ElementCallIntent.JoinExisting); }); - - it("requests visual notifications in non-DMs", async () => { - ElementCall.create(room); - const call = Call.get(room); - if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); - - const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); - expect(urlParams.get("sendNotificationType")).toBe("notification"); - }); }); describe("instance in a non-video room", () => { @@ -744,7 +734,7 @@ describe("ElementCall", () => { jest.useFakeTimers(); jest.setSystemTime(0); - ElementCall.create(room, true); + ElementCall.create(room); const maybeCall = ElementCall.get(room); if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; @@ -762,7 +752,7 @@ describe("ElementCall", () => { WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const startup = call.start(); + const startup = call.start({}); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); await startup; await connect(call, messaging, false); From 7f39bb61ec3ab5031edd7c463de72f482d10b2bf Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 25 Sep 2025 15:28:04 +0100 Subject: [PATCH 155/187] Rich Text Editor: Add emoji suggestion support (#30873) * Add support for emoji suggestions To both the rich text/plain text modes of the RTE. * Add emoji completion test to WysiwygComposer * Fix code as per test case, do no-op for community case * bump wysiwyg to the version with suggestions supported. * Add more unit tests for processTextReplacement --- package.json | 2 +- .../components/PlainTextComposer.tsx | 2 + .../components/WysiwygAutocomplete.tsx | 14 +++ .../components/WysiwygComposer.tsx | 1 + .../hooks/usePlainTextListeners.ts | 13 ++- .../wysiwyg_composer/hooks/useSuggestion.ts | 34 ++++++- .../components/WysiwygAutocomplete-test.tsx | 3 + .../components/WysiwygComposer-test.tsx | 15 ++++ .../hooks/useSuggestion-test.tsx | 90 +++++++++++++++++++ yarn.lock | 10 +-- 10 files changed, 173 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 2cfb80d3b4..c36ae5d4eb 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@types/png-chunks-extract": "^1.0.2", "@vector-im/compound-design-tokens": "^6.0.0", "@vector-im/compound-web": "^8.1.2", - "@vector-im/matrix-wysiwyg": "2.39.0", + "@vector-im/matrix-wysiwyg": "2.40.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index 33d8e88e03..aadb3c0e78 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -60,6 +60,7 @@ export function PlainTextComposer({ handleCommand, handleMention, handleAtRoomMention, + handleEmoji, } = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled); const composerFunctions = useComposerFunctions(editorRef, setContent); usePlainTextInitialization(initialContent, editorRef); @@ -84,6 +85,7 @@ export function PlainTextComposer({ handleMention={handleMention} handleCommand={handleCommand} handleAtRoomMention={handleAtRoomMention} + handleEmoji={handleEmoji} /> ; } @@ -55,6 +61,7 @@ const WysiwygAutocomplete = ({ handleMention, handleCommand, handleAtRoomMention, + handleEmoji, ref, }: WysiwygAutocompleteProps): JSX.Element | null => { const { room } = useScopedRoomContext("room"); @@ -89,7 +96,14 @@ const WysiwygAutocomplete = ({ return; } // TODO - handle "community" type + case "community": { + return; // no-op until we decide how to handle community in the wysiwyg composer + } default: + { + // similar to the cider editor we handle emoji and other plain text replacement in the default case + handleEmoji(completion.completion); + } return; } } diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 10b13a3523..b0696143aa 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -127,6 +127,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ handleMention={wysiwyg.mention} handleAtRoomMention={wysiwyg.mentionAtRoom} handleCommand={wysiwyg.command} + handleEmoji={wysiwyg.emoji} /> void; handleAtRoomMention: (attributes: AllowedMentionAttributes) => void; handleCommand: (text: string) => void; + handleEmoji: (emoji: string) => void; onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { @@ -95,8 +96,15 @@ export function usePlainTextListeners( // For separation of concerns, the suggestion handling is kept in a separate hook but is // nested here because we do need to be able to update the `content` state in this hook // when a user selects a suggestion from the autocomplete menu - const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention, handleEmojiReplacement } = - useSuggestion(ref, setText, isAutoReplaceEmojiEnabled); + const { + suggestion, + onSelect, + handleCommand, + handleMention, + handleAtRoomMention, + handleEmojiSuggestion, + handleEmojiReplacement, + } = useSuggestion(ref, setText, isAutoReplaceEmojiEnabled); const onInput = useCallback( (event: SyntheticEvent) => { @@ -178,5 +186,6 @@ export function usePlainTextListeners( handleCommand, handleMention, handleAtRoomMention, + handleEmoji: handleEmojiSuggestion, }; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 89d189257a..2aae412eb7 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -52,6 +52,7 @@ export function useSuggestion( handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void; handleAtRoomMention: (attributes: AllowedMentionAttributes) => void; handleCommand: (text: string) => void; + handleEmojiSuggestion: (text: string) => void; handleEmojiReplacement: () => void; onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; @@ -86,11 +87,15 @@ export function useSuggestion( const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText); + const handleEmojiSuggestion = (emoji: string): void => + processTextReplacement(emoji, suggestionData, setSuggestionData, setText); + return { suggestion: suggestionData?.mappedSuggestion ?? null, handleCommand, handleMention, handleAtRoomMention, + handleEmojiSuggestion, handleEmojiReplacement, onSelect, }; @@ -260,10 +265,31 @@ export function processEmojiReplacement( setText: (text?: string) => void, ): void { // if we do not have a suggestion of the correct type, return early - if (suggestionData === null || suggestionData.mappedSuggestion.type !== `custom`) { + if (suggestionData?.mappedSuggestion?.type !== `custom`) { return; } - const { node, mappedSuggestion } = suggestionData; + + processTextReplacement(suggestionData.mappedSuggestion.text, suggestionData, setSuggestionData, setText); +} + +/** + * Replaces the relevant part of the editor text, replacing the suggestionData selection with the replacement text. + * @param replacementText - the text that we will insert into the DOM + * @param suggestionData - representation of the part of the DOM that will be replaced + * @param setSuggestionData - setter function to set the suggestion state + * @param setText - setter function to set the content of the composer + */ +export function processTextReplacement( + replacementText: string, + suggestionData: SuggestionState, + setSuggestionData: React.Dispatch>, + setText: (text?: string) => void, +): void { + // if we do not have suggestion data return early + if (suggestionData === null) { + return; + } + const { node } = suggestionData; const existingContent = node.textContent; if (existingContent == null) { @@ -273,7 +299,7 @@ export function processEmojiReplacement( // replace the emoticon with the suggesed emoji const newContent = existingContent.slice(0, suggestionData.startOffset) + - mappedSuggestion.text + + replacementText + existingContent.slice(suggestionData.endOffset); node.textContent = newContent; @@ -405,6 +431,8 @@ export function getMappedSuggestion(text: string, isAutoReplaceEmojiEnabled?: bo case "#": case "@": return { keyChar: firstChar, text: restOfString, type: "mention" }; + case ":": + return { keyChar: firstChar, text: restOfString, type: "emoji" }; default: return null; } diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx index 8a3d0af791..70809716a4 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx @@ -66,6 +66,7 @@ describe("WysiwygAutocomplete", () => { const mockHandleMention = jest.fn(); const mockHandleCommand = jest.fn(); const mockHandleAtRoomMention = jest.fn(); + const mockHandleEmoji = jest.fn(); const renderComponent = (props: Partial> = {}) => { const mockClient = stubClient(); @@ -81,6 +82,7 @@ describe("WysiwygAutocomplete", () => { handleMention={mockHandleMention} handleCommand={mockHandleCommand} handleAtRoomMention={mockHandleAtRoomMention} + handleEmoji={mockHandleEmoji} {...props} /> @@ -96,6 +98,7 @@ describe("WysiwygAutocomplete", () => { handleMention={mockHandleMention} handleCommand={mockHandleCommand} handleAtRoomMention={mockHandleAtRoomMention} + handleEmoji={mockHandleEmoji} />, ); expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument(); diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index 610003b8ff..ce4eb8634f 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -234,6 +234,11 @@ describe("WysiwygComposer", () => { range: { start: 1, end: 1 }, component:
community
, }, + { + completion: "😄", + range: { start: 1, end: 1 }, + component:
😄
, + }, ]; const constructMockProvider = (data: ICompletion[]) => @@ -435,6 +440,16 @@ describe("WysiwygComposer", () => { // check that it we still have the initial text expect(screen.getByText(initialInput)).toBeInTheDocument(); }); + + it("selecting an emoji suggestion inserts the emoji", async () => { + await insertMentionInput(); + + // select the room suggestion + await userEvent.click(screen.getByText("😄")); + + // check that it has inserted the plain text + expect(screen.getByText("😄")).toBeInTheDocument(); + }); }); describe("When emoticons should be replaced by emojis", () => { diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx index 203f82cc66..8adc38a638 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -14,6 +14,7 @@ import { processEmojiReplacement, processMention, processSelectionChange, + processTextReplacement, } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion"; function createMockPlainTextSuggestionPattern(props: Partial = {}): Suggestion { @@ -382,6 +383,95 @@ describe("findSuggestionInText", () => { }); }); +describe("processTextReplacement", () => { + it("does not change parent hook state if suggestionData is null", () => { + const mockSetSuggestionData = jest.fn(); + const mockSetText = jest.fn(); + const replacementText = "replacement"; + + // call the function with null suggestionData + processTextReplacement(replacementText, null, mockSetSuggestionData, mockSetText); + + // check that the parent state setters have not been called + expect(mockSetText).not.toHaveBeenCalled(); + expect(mockSetSuggestionData).not.toHaveBeenCalled(); + }); + + it("does not change parent hook state if existingContent is null", () => { + const mockSetSuggestionData = jest.fn(); + const mockSetText = jest.fn(); + const replacementText = "replacement"; + + // create a mock node with null textContent + const mockNode = { + textContent: null, + } as unknown as Text; + + const mockSuggestion: Suggestion = { + mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" }, + node: mockNode, + startOffset: 0, + endOffset: 2, + }; + + // call the function with a node that has null textContent + processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText); + + // check that the parent state setters have not been called + expect(mockSetText).not.toHaveBeenCalled(); + expect(mockSetSuggestionData).not.toHaveBeenCalled(); + }); + + it("can replace text content when both suggestionData and existingContent are valid", () => { + const mockSetSuggestionData = jest.fn(); + const mockSetText = jest.fn(); + const replacementText = "🙂"; + const initialText = "Hello :) world"; + + // create a div and append a text node to it + const editorDiv = document.createElement("div"); + const textNode = document.createTextNode(initialText); + editorDiv.appendChild(textNode); + document.body.appendChild(editorDiv); + + const mockSuggestion: Suggestion = { + mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" }, + node: textNode, + startOffset: 6, // position of ":)" + endOffset: 8, // end of ":)" + }; + + // mock document.getSelection + const mockSelection = { + setBaseAndExtent: jest.fn(), + }; + jest.spyOn(document, "getSelection").mockReturnValue(mockSelection as any); + + // call the function + processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText); + + // check that the text content was updated correctly + expect(textNode.textContent).toBe("Hello 🙂 world"); + + // check that setText was called with the new content + expect(mockSetText).toHaveBeenCalledWith("Hello 🙂 world"); + + // check that suggestionData was cleared + expect(mockSetSuggestionData).toHaveBeenCalledWith(null); + + // check that the cursor was positioned at the end + expect(mockSelection.setBaseAndExtent).toHaveBeenCalledWith( + textNode, + "Hello 🙂 world".length, + textNode, + "Hello 🙂 world".length, + ); + + // clean up + document.body.removeChild(editorDiv); + }); +}); + describe("getMappedSuggestion", () => { it("returns null when the first character is not / # @", () => { expect(getMappedSuggestion("Zzz")).toBe(null); diff --git a/yarn.lock b/yarn.lock index 39d178a0dd..59460e89c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4752,12 +4752,12 @@ version "0.0.0" uid "" -"@vector-im/matrix-wysiwyg@2.39.0": - version "2.39.0" - resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.39.0.tgz#a6238e517f23a2f3025d9c65445914771c63b163" - integrity sha512-OROXnzPcQWrCMoUpIrCKEC4FYU+9SsRomUgu+VbJwWtBDkCbfvLD4z6w/mgiADw3iTUpBPgmcWJoGxesFuB20Q== +"@vector-im/matrix-wysiwyg@2.40.0": + version "2.40.0" + resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c" + integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" "@vitest/expect@3.2.4": version "3.2.4" From e225c23fba37a82746c7d6c87b63cbb0dcd8dc98 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Fri, 26 Sep 2025 07:18:29 +0100 Subject: [PATCH 156/187] [create-pull-request] automated change (#30885) 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 f153cdb34c..b234f9f389 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -const TAG = "develop@sha256:5159141547ec7fc65e956226bd0f1349c5ef1e26ad72bfa25105f821cac74be2"; +const TAG = "develop@sha256:0fd823705517826336ed5831093b1cf0b00f535884926cee994100dcddf15d1f"; /** * SynapseContainer which freezes the docker digest to stabilise tests, From 88d4f369ebddedb0d51ca9cf744eb57b041339a6 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 26 Sep 2025 09:59:40 +0100 Subject: [PATCH 157/187] Change the title of VerificationRequestDialog when a request is cancelled (#30879) * Test that VerificationRequestDialog updates when phase changes * Change the title of VerificationRequestDialog when a request is cancelled Part of implementing https://github.com/element-hq/element-meta/issues/2898 but split out as a separate change because it involves making VerificationRequestDialog listen for changes to the verificationRequest so it can update based on changes to phase. --- .../dialogs/VerificationRequestDialog.tsx | 61 ++++++- src/i18n/strings/en_EN.json | 1 + .../components/structures/MatrixChat-test.tsx | 4 +- .../VerificationRequestDialog-test.tsx | 162 +++++++++++++++--- .../VerificationRequestDialog-test.tsx.snap | 2 +- 5 files changed, 197 insertions(+), 33 deletions(-) diff --git a/src/components/views/dialogs/VerificationRequestDialog.tsx b/src/components/views/dialogs/VerificationRequestDialog.tsx index cb4166b775..801b745e71 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.tsx +++ b/src/components/views/dialogs/VerificationRequestDialog.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { type VerificationRequest } from "matrix-js-sdk/src/crypto-api"; +import { VerificationPhase, VerificationRequestEvent, type VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { type User } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -23,7 +23,17 @@ interface IProps { } interface IState { + // The VerificationRequest that is ongoing. This can be replaced if a + // promise was supplied in the props and it completes. verificationRequest?: VerificationRequest; + + // What phase the VerificationRequest is at. This is part of + // verificationRequest but we have it as independent state because we need + // to update when it changes. + // + // We listen to the `Change` event on verificationRequest and update phase + // when that fires. + phase?: VerificationPhase; } export default class VerificationRequestDialog extends React.Component { @@ -31,22 +41,51 @@ export default class VerificationRequestDialog extends React.Component { - this.setState({ verificationRequest: r }); + // The request promise completed, so we have a new request + + // Stop listening to the old request (if we have one, which normally we won't) + this.state.verificationRequest?.off(VerificationRequestEvent.Change, this.onRequestChange); + + // And start listening to the new one + r.on(VerificationRequestEvent.Change, this.onRequestChange); + + this.setState({ verificationRequest: r, phase: r.phase }); }); } + public componentWillUnmount(): void { + // Stop listening for changes to the request when we close + this.state.verificationRequest?.off(VerificationRequestEvent.Change, this.onRequestChange); + } + + /** + * The verificationRequest changed, so we need to make sure we update our + * state to have the correct phase. + * + * Note: this is called when verificationRequest changes in some way, not + * when we replace verificationRequest with some new request. + */ + private readonly onRequestChange = (): void => { + this.setState((prevState) => ({ + phase: prevState.verificationRequest?.phase, + })); + }; + public render(): React.ReactNode { const request = this.state.verificationRequest; const otherUserId = request?.otherUserId; const member = this.props.member || (otherUserId ? MatrixClientPeg.safeGet().getUser(otherUserId) : null); - const title = request?.isSelfVerification - ? _t("encryption|verification|verification_dialog_title_device") - : _t("encryption|verification|verification_dialog_title_user"); + const title = this.dialogTitle(request); if (!member) return null; @@ -69,4 +108,16 @@ export default class VerificationRequestDialog extends React.Component ); } + + private dialogTitle(request?: VerificationRequest): string { + if (request?.isSelfVerification) { + if (request.phase === VerificationPhase.Cancelled) { + return _t("encryption|verification|verification_dialog_title_failed"); + } else { + return _t("encryption|verification|verification_dialog_title_device"); + } + } else { + return _t("encryption|verification|verification_dialog_title_user"); + } + } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 811edc1590..f9eab0839e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1068,6 +1068,7 @@ "use_another_device": "Use another device", "use_recovery_key": "Use recovery key", "verification_dialog_title_device": "Verify other device", + "verification_dialog_title_failed": "Verification failed", "verification_dialog_title_user": "Verification Request", "verification_skip_warning": "Without verifying, you won't have access to all your messages and may appear as untrusted to others.", "verification_success_with_backup": "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.", diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index eef9b999d2..3ebe826b1c 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -1106,7 +1106,7 @@ describe("", () => { act(() => verify.click()); // And close the device verification dialog - const closeButton = await screen.findByRole("button", { name: "Close dialog" }); + const closeButton = screen.getByRole("button", { name: "Close dialog" }); act(() => closeButton.click()); // Then we are not allowed in - we are still being asked to verify @@ -1179,7 +1179,7 @@ describe("", () => { .fn() .mockResolvedValue({ signedByOwner: true } as DeviceVerificationStatus), isCrossSigningReady: jest.fn().mockReturnValue(false), - requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn() }), + requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn(), on: jest.fn() }), } as any; } }); diff --git a/test/unit-tests/components/views/dialogs/VerificationRequestDialog-test.tsx b/test/unit-tests/components/views/dialogs/VerificationRequestDialog-test.tsx index e585f8a065..f373cfef14 100644 --- a/test/unit-tests/components/views/dialogs/VerificationRequestDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/VerificationRequestDialog-test.tsx @@ -7,18 +7,20 @@ 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 { TypedEventEmitter, User } from "matrix-js-sdk/src/matrix"; import { type ShowSasCallbacks, VerificationPhase, type Verifier, type VerificationRequest, type ShowQrCodeCallbacks, + VerificationRequestEvent, + type VerificationRequestEventHandlerMap, } 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"; +import VerificationRequestDialog from "../../../../../src/components/views/dialogs/VerificationRequestDialog"; describe("VerificationRequestDialog", () => { function renderComponent(phase: VerificationPhase, method?: "emoji" | "qr"): ReturnType { @@ -86,7 +88,7 @@ describe("VerificationRequestDialog", () => { 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 failed" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Verification cancelled" })).toBeInTheDocument(); expect( @@ -116,7 +118,7 @@ describe("VerificationRequestDialog", () => { 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 failed" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Verification cancelled" })).toBeInTheDocument(); expect( @@ -146,7 +148,28 @@ describe("VerificationRequestDialog", () => { 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 failed" })).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("Changes the dialog contents when the request changes phase", async () => { + // Given we rendered the component with a phase of Unsent + const member = User.createUser("@alice:example.org", stubClient()); + const request = createRequest(VerificationPhase.Unsent); + + render(); + + // When I cancel the request (which changes phase and emits a Changed event) + await act(async () => await request.cancel()); + + // Then the dialog is updated to reflect that + expect(screen.getByRole("heading", { name: "Verification failed" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Verification cancelled" })).toBeInTheDocument(); expect( @@ -157,9 +180,9 @@ describe("VerificationRequestDialog", () => { }); }); -function createRequest(phase: VerificationPhase, method?: "emoji" | "qr"): VerificationRequest { +function createRequest(phase: VerificationPhase, method?: "emoji" | "qr"): MockVerificationRequest { let verifier = undefined; - let chosenMethod = undefined; + let chosenMethod = null; switch (method) { case "emoji": @@ -172,24 +195,7 @@ function createRequest(phase: VerificationPhase, method?: "emoji" | "qr"): Verif 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; + return new MockVerificationRequest(phase, verifier, chosenMethod); } function createEmojiVerifier(): Verifier { @@ -226,3 +232,109 @@ function createQrVerifier(): Verifier { verify: jest.fn(), } as unknown as Verifier; } + +class MockVerificationRequest + extends TypedEventEmitter + implements VerificationRequest +{ + phase_: VerificationPhase; + verifier_: Verifier | undefined; + chosenMethod_: string | null; + + constructor(phase: VerificationPhase, verifier: Verifier | undefined, chosenMethod: string | null) { + super(); + this.phase_ = phase; + this.verifier_ = verifier; + this.chosenMethod_ = chosenMethod; + } + + get phase(): VerificationPhase { + return this.phase_; + } + + get isSelfVerification(): boolean { + // So far we are only testing verification of our own devices + return true; + } + + get initiatedByMe(): boolean { + // So far we are only testing verification started by this device + return true; + } + + otherPartySupportsMethod(): boolean { + // This makes both emoji and QR verification options appear + return true; + } + + get verifier(): Verifier | undefined { + return this.verifier_; + } + + get chosenMethod(): string | null { + return this.chosenMethod_; + } + + async cancel(): Promise { + this.phase_ = VerificationPhase.Cancelled; + this.emit(VerificationRequestEvent.Change); + } + + get transactionId(): string | undefined { + return undefined; + } + + get roomId(): string | undefined { + return undefined; + } + + get otherUserId(): string { + return "otheruser"; + } + + get otherDeviceId(): string | undefined { + return undefined; + } + + get pending(): boolean { + return false; + } + + get accepting(): boolean { + return false; + } + + get declining(): boolean { + return false; + } + + get timeout(): number | null { + return null; + } + + get methods(): string[] { + return []; + } + + async accept(): Promise {} + + startVerification(_method: string): Promise { + throw new Error("Method not implemented."); + } + + scanQRCode(_qrCodeData: Uint8ClampedArray): Promise { + throw new Error("Method not implemented."); + } + + async generateQRCode(): Promise { + return undefined; + } + + get cancellationCode(): string | null { + return null; + } + + get cancellingUserId(): string | undefined { + return "otheruser"; + } +} 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 index f0336ef559..a2fd15edd5 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/VerificationRequestDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/VerificationRequestDialog-test.tsx.snap @@ -236,7 +236,7 @@ exports[`VerificationRequestDialog Shows a failure message if verification was c class="mx_Heading_h3 mx_Dialog_title" id="mx_BaseDialog_title" > - Verify other device + Verification failed