diff --git a/apps/webapp/src/script/components/Conversation/Conversation.tsx b/apps/webapp/src/script/components/Conversation/Conversation.tsx index 791c6e4a0d8..8b4549efd6d 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'; @@ -296,8 +299,48 @@ 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!; + + const parsed = parseAccountDeepLink(href, CONFIG.URL.ACCOUNT_BASE); + + if (parsed?.type === 'user-profile') { + event.preventDefault(); + void openUserProfile(parsed.id, parsed.domain); + 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.test.ts b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts new file mode 100644 index 00000000000..59b6a10a80f --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.test.ts @@ -0,0 +1,85 @@ +/* + * 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 {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(); + }); +}); 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..2f02dfcea9b --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/utils/parseAccountDeepLink.ts @@ -0,0 +1,99 @@ +/* + * 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 => { + let end = pathname.length; + + while (end > 0 && pathname[end - 1] === '/') { + end--; + } + + return pathname.slice(0, end); +}; + +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; + } + + 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; +};