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 53/54] 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 54/54] [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,