From cfc6bdfaf97b27e7c6eab68bd51f18ea8a8f37d5 Mon Sep 17 00:00:00 2001 From: lehnerja Date: Tue, 24 Mar 2026 17:46:15 +0100 Subject: [PATCH 1/5] feat(web): handle Wire account deep links inside conversation Intercept Wire account deep links from chat messages and resolve them in-app instead of opening them as external links in a new tab. - add parseAccountDeepLink.ts helper for deep link detection - support user-profile and conversation-join deep links - parse qualified user ids from profile links - reuse current session by opening supported links in-app - keep external link modal flow for unsupported links --- .../components/Conversation/Conversation.tsx | 41 +++++++++ .../utils/parseAccountDeepLink.ts | 92 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts diff --git a/apps/webapp/src/script/components/Conversation/Conversation.tsx b/apps/webapp/src/script/components/Conversation/Conversation.tsx index 791c6e4a0d8..ae8f2f6a384 100644 --- a/apps/webapp/src/script/components/Conversation/Conversation.tsx +++ b/apps/webapp/src/script/components/Conversation/Conversation.tsx @@ -23,13 +23,16 @@ import {CONVERSATION_CELLS_STATE} from '@wireapp/api-client/lib/conversation'; import {container} from 'tsyringe'; import {useMatchMedia} from '@wireapp/react-ui-kit'; +import {WebAppEvents} from '@wireapp/webapp-events'; import {CallingCell} from 'Components/calling/CallingCell'; +import {parseAccountDeepLink} from 'Components/Conversation/utils/parseAccountDeepLink'; import {Giphy} from 'Components/Giphy'; import {InputBar} from 'Components/InputBar'; import {MessageListWrapper} from 'Components/MessagesList/MessageListWrapper'; import {showDetailViewModal} from 'Components/Modals/DetailViewModal'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; +import {showUserModal} from 'Components/Modals/UserModal'; import {showWarningModal} from 'Components/Modals/utils/showWarningModal'; import {TitleBar} from 'Components/TitleBar'; import {CallState} from 'Repositories/calling/CallState'; @@ -298,6 +301,44 @@ export const Conversation = ({ const handleMarkdownLinkClick = (event: MouseEvent | KeyboardEvent, messageDetails: MessageDetails) => { const href = messageDetails.href!; + + const parsed = parseAccountDeepLink(href, CONFIG.URL.ACCOUNT_BASE); + + if (parsed?.type === 'user-profile') { + event.preventDefault(); + + void (async () => { + try { + const userEntity = await repositories.user.getUserById({ + id: parsed.id, + domain: parsed.domain ?? '', + }); + + showUserModal(userEntity); + } catch (error: unknown) { + if (error instanceof UserError && error.type !== UserError.TYPE.USER_NOT_FOUND) { + throw error; + } + } + })(); + + return false; + } + + if (parsed?.type === 'conversation-join') { + event.preventDefault(); + + window.dispatchEvent( + new CustomEvent(WebAppEvents.CONVERSATION.JOIN, { + detail: { + key: parsed.key, + code: parsed.code, + domain: parsed.domain, + }, + }), + ); + return false; + } PrimaryModal.show(PrimaryModal.type.CONFIRM, { primaryAction: { action: () => safeWindowOpen(href), diff --git a/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts new file mode 100644 index 00000000000..d43c0210463 --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts @@ -0,0 +1,92 @@ +/* + * Wire + * Copyright (C) 2022 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type ParseAccountDeepLink = + | {type: 'user-profile'; id: string; domain?: string} + | {type: 'conversation-join'; key: string; code: string; domain?: string} + | null; + +type ParsedQualifiedId = { + id: string; + domain?: string; +}; + +const parseQualifiedUserId = (value: string): ParsedQualifiedId => { + const atIndex = value.lastIndexOf('@'); + if (atIndex <= 0) { + return {id: value}; + } + + return { + id: value.slice(0, atIndex), + domain: value.slice(atIndex + 1) || undefined, + }; +}; + +const normalizePath = (pathname: string): string => pathname.replace(/\/+$/, ''); + +const normalizeOrigin = (url: URL): string => url.origin.toLowerCase(); + +export const parseAccountDeepLink = (href: string, accountBase?: string): ParseAccountDeepLink => { + if (!href || !accountBase) { + return null; + } + let linkUrl: URL; + let accountBaseUrl: URL; + + try { + linkUrl = new URL(href); + accountBaseUrl = new URL(accountBase); + } catch { + return null; + } + + if (normalizeOrigin(linkUrl) !== normalizeOrigin(accountBaseUrl)) { + return null; + } + + // use regex https:\/\/[\w.-]+\/(?:user-profile\/\?id=([\w-]+)@|conversation-join\/\?key=([\w-]+)&code=([\w-]+)&domain=)([\w.-]+\.[a-zA-Z]{2,}) + const pathname = normalizePath(linkUrl.pathname); + + if (pathname === '/user-profile') { + const rawId = linkUrl.searchParams.get('id'); + const explicitDomain = linkUrl.searchParams.get('domain') || undefined; + + if (!rawId) { + return null; + } + + const qualified = parseQualifiedUserId(rawId); + + return {type: 'user-profile', id: qualified.id, domain: explicitDomain ?? qualified.domain}; + } + + if (pathname === '/conversation-join') { + const key = linkUrl.searchParams.get('key'); + const code = linkUrl.searchParams.get('code'); + const domain = linkUrl.searchParams.get('domain') || undefined; + + if (!key || !code) { + return null; + } + return {type: 'conversation-join', key, code, domain}; + } + + return null; +}; From 89fac701191b4fa71c5d522699e1c9e5ac69d37b Mon Sep 17 00:00:00 2001 From: lehnerja Date: Tue, 24 Mar 2026 17:54:07 +0100 Subject: [PATCH 2/5] test(web): add unit tests for account deep link parsing Add tests for parseAccountDeepLink covering user-profile and conversation-join links, explicit domain handling, unsupported origins, and missing parameters. --- .../utils/parseAccountDeepLink.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts diff --git a/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts new file mode 100644 index 00000000000..6029affc47e --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts @@ -0,0 +1,86 @@ +/* + * Wire + * Copyright (C) 2022 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {expect} from 'playwright/test'; +import {parseAccountDeepLink} from 'Components/Conversation/utils/parseAccountDeepLink'; + +describe('parseAccountDeepLink', () => { + const accountBase = 'https://account.wire.com'; + + it('parses a user-profile link with raw id and explicit domain', () => { + expect( + parseAccountDeepLink('https://account.wire.com/user-profile/?id=user-123&domain=wire.com', accountBase), + ).toEqual({ + type: 'user-profile', + id: 'user-123', + domain: 'wire.com', + }); + }); + + it('prefers explicit domain over domain embedded in id', () => { + expect( + parseAccountDeepLink( + 'https://account.wire.com/user-profile/?id=user-123@old.example&domain=new.example', + accountBase, + ), + ).toEqual({ + type: 'user-profile', + id: 'user-123', + domain: 'new.example', + }); + }); + + it('parses a conversation-join link', () => { + expect( + parseAccountDeepLink( + 'https://account.wire.com/conversation-join/?key=test-key&code=test-code&domain=wire.com', + accountBase, + ), + ).toEqual({ + type: 'conversation-join', + key: 'test-key', + code: 'test-code', + domain: 'wire.com', + }); + }); + + it('returns null for a different origin', () => { + expect(parseAccountDeepLink('https://google.com/user-profile/?id=user-123', accountBase)).toBeNull(); + }); + + it('returns null when user-profile is missing id', () => { + expect(parseAccountDeepLink('https://account.wire.com/user-profile/', accountBase)).toBeNull(); + }); + + it('returns null when conversation-join is missing key or code', () => { + expect(parseAccountDeepLink('https://account.wire.com/conversation-join/?key=test-key', accountBase)).toBeNull(); + }); + + it('returns null for unsupported account paths', () => { + expect(parseAccountDeepLink('https://account.wire.com/something-else/?id=user=123', accountBase)).toBeNull(); + }); + + it('returns null for invalid urls', () => { + expect(parseAccountDeepLink('not-a-url', accountBase)).toBeNull(); + }); + + it('returns null when accountBase is missing', () => { + expect(parseAccountDeepLink('https://account.wire.com/user-profile/?id=user-123', undefined)).toBeNull(); + }); +}); From 145522d5de4c9de2d97617effe9c190c81a8f3e0 Mon Sep 17 00:00:00 2001 From: lehnerja Date: Wed, 25 Mar 2026 09:01:32 +0100 Subject: [PATCH 3/5] feat(web): replaced the trailing-slash regex with a linear string scan --- .../Conversation/utils/parseAccountDeepLink.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts index d43c0210463..2f02dfcea9b 100644 --- a/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts +++ b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts @@ -39,7 +39,15 @@ const parseQualifiedUserId = (value: string): ParsedQualifiedId => { }; }; -const normalizePath = (pathname: string): string => pathname.replace(/\/+$/, ''); +const normalizePath = (pathname: string): string => { + let end = pathname.length; + + while (end > 0 && pathname[end - 1] === '/') { + end--; + } + + return pathname.slice(0, end); +}; const normalizeOrigin = (url: URL): string => url.origin.toLowerCase(); @@ -61,7 +69,6 @@ export const parseAccountDeepLink = (href: string, accountBase?: string): ParseA return null; } - // use regex https:\/\/[\w.-]+\/(?:user-profile\/\?id=([\w-]+)@|conversation-join\/\?key=([\w-]+)&code=([\w-]+)&domain=)([\w.-]+\.[a-zA-Z]{2,}) const pathname = normalizePath(linkUrl.pathname); if (pathname === '/user-profile') { From c7c3f84011b775beaacf384e1a994588b67201ad Mon Sep 17 00:00:00 2001 From: lehnerja Date: Fri, 27 Mar 2026 11:14:53 +0100 Subject: [PATCH 4/5] feat(web): changed expect import --- .../components/Conversation/utils/parseAccountDeepLink.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts index 6029affc47e..59b6a10a80f 100644 --- a/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts +++ b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts @@ -17,7 +17,6 @@ * */ -import {expect} from 'playwright/test'; import {parseAccountDeepLink} from 'Components/Conversation/utils/parseAccountDeepLink'; describe('parseAccountDeepLink', () => { From 24ff011c3e20240f9db4247797360204f146e518 Mon Sep 17 00:00:00 2001 From: lehnerja Date: Mon, 30 Mar 2026 16:26:44 +0200 Subject: [PATCH 5/5] feat(web): extract deep link user lookup and handle missing users explicitly Move async profile deep link handling into a dedicated helper to remove the inline IIFE in Conversation.tsx. Also handle UserError.TYPE.USER_NOT_FOUND explicitly by logging it instead of swallowing the error. --- .../components/Conversation/Conversation.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/webapp/src/script/components/Conversation/Conversation.tsx b/apps/webapp/src/script/components/Conversation/Conversation.tsx index ae8f2f6a384..8b4549efd6d 100644 --- a/apps/webapp/src/script/components/Conversation/Conversation.tsx +++ b/apps/webapp/src/script/components/Conversation/Conversation.tsx @@ -299,6 +299,23 @@ export const Conversation = ({ return false; }; + const openUserProfile = async (id: string, domain?: string) => { + try { + const userEntity = await repositories.user.getUserById({ + id, + domain: domain ?? '', + }); + + showUserModal(userEntity); + } catch (error: unknown) { + if (error instanceof UserError && error.type === UserError.TYPE.USER_NOT_FOUND) { + messageListLogger.warn('Could not resolve user profile deep link', {id, domain}); + return; + } + throw error; + } + }; + const handleMarkdownLinkClick = (event: MouseEvent | KeyboardEvent, messageDetails: MessageDetails) => { const href = messageDetails.href!; @@ -306,22 +323,7 @@ export const Conversation = ({ if (parsed?.type === 'user-profile') { event.preventDefault(); - - void (async () => { - try { - const userEntity = await repositories.user.getUserById({ - id: parsed.id, - domain: parsed.domain ?? '', - }); - - showUserModal(userEntity); - } catch (error: unknown) { - if (error instanceof UserError && error.type !== UserError.TYPE.USER_NOT_FOUND) { - throw error; - } - } - })(); - + void openUserProfile(parsed.id, parsed.domain); return false; }