From 4571745654abf6d6fb36fe79d2074aebada4cb8b Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Mon, 4 Dec 2023 14:39:40 +0100 Subject: [PATCH] Introduce deep link parser (#4038) --- src/components/common/UsernameInput.tsx | 14 +- src/util/deepLinkParser.ts | 341 ++++++++++++++++++++++++ src/util/deeplink.ts | 5 +- src/util/username.ts | 11 + 4 files changed, 356 insertions(+), 15 deletions(-) create mode 100644 src/util/deepLinkParser.ts create mode 100644 src/util/username.ts diff --git a/src/components/common/UsernameInput.tsx b/src/components/common/UsernameInput.tsx index 87cb635eb..a8db376ff 100644 --- a/src/components/common/UsernameInput.tsx +++ b/src/components/common/UsernameInput.tsx @@ -6,6 +6,9 @@ import { getActions } from '../../global'; import { TME_LINK_PREFIX } from '../../config'; import { debounce } from '../../util/schedulers'; +import { + isUsernameValid, MAX_USERNAME_LENGTH, MIN_USERNAME_LENGTH, USERNAME_REGEX, +} from '../../util/username'; import useLang from '../../hooks/useLang'; import usePrevious from '../../hooks/usePrevious'; @@ -21,21 +24,10 @@ type OwnProps = { onChange: (value: string) => void; }; -const MIN_USERNAME_LENGTH = 5; -const MAX_USERNAME_LENGTH = 32; const LINK_PREFIX_REGEX = /https:\/\/t\.me\/?/i; -const USERNAME_REGEX = /^\D([a-zA-Z0-9_]+)$/; const runDebouncedForCheckUsername = debounce((cb) => cb(), 250, false); -function isUsernameValid(username: string) { - return username.length === 0 || ( - username.length >= MIN_USERNAME_LENGTH - && username.length <= MAX_USERNAME_LENGTH - && USERNAME_REGEX.test(username) - ); -} - const UsernameInput: FC = ({ currentUsername, asLink, diff --git a/src/util/deepLinkParser.ts b/src/util/deepLinkParser.ts new file mode 100644 index 000000000..bd9eb9f95 --- /dev/null +++ b/src/util/deepLinkParser.ts @@ -0,0 +1,341 @@ +import { isUsernameValid } from './username'; + +export type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' | +'setlanguage' | 'addtheme' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' | 'msg' | 'msg_url' | +'invoice' | 'addlist' | 'boost' | 'giftcode'; + +export enum DeepLinkType { + PublicMessageLink = 'PublicMessageLink', + PrivateMessageLink = 'PrivateMessageLink', + ShareLink = 'ShareLink', + ChatFolderLink = 'ChatFolderLink', + Unknown = 'Unknown', +} + +interface PublicMessageLink { + type: DeepLinkType.PublicMessageLink; + username: string; + messageId: number; + isSingle?: boolean; + threadId?: number; + commentId?: number; + mediaTimestamp?: string; +} + +interface PrivateMessageLink { + type: DeepLinkType.PrivateMessageLink; + channelId: string; + messageId: number; + isSingle?: boolean; + threadId?: number; + commentId?: number; + mediaTimestamp?: string; +} + +interface ShareLink { + type: DeepLinkType.ShareLink; + url: string; + text?: string; +} + +interface ChatFolderLink { + type: DeepLinkType.ChatFolderLink; + slug: string; +} + +type DeepLink = PublicMessageLink | PrivateMessageLink | ShareLink | ChatFolderLink; + +type BuilderParams = Record, string>; +type BuilderReturnType = T | undefined; + +const ELIGIBLE_HOSTNAMES = new Set(['t.me', 'telegram.me', 'telegram.dog']); + +export function tryParseDeepLink(link: string): DeepLink | undefined { + try { + return parseDeepLink(link); + } catch (err) { + return undefined; + } +} + +function parseDeepLink(url: string) { + if (url.startsWith('https:')) { + const urlParsed = new URL(url); + return handleHttpLink(urlParsed); + } + if (url.startsWith('tg:')) { + // Chrome parse url with tg: protocol incorrectly + const urlParsed = new URL(url.replace(/^tg:/, 'http:')); + return handleTgLink(urlParsed); + } + return undefined; +} + +function handleTgLink(url: URL) { + const { hostname } = url; + const queryParams = getQueryParams(url); + const pathParams = getPathParams(url); + const method = hostname as DeepLinkMethod; + + const deepLinkType = getTgDeepLinkType(queryParams, pathParams, method); + switch (deepLinkType) { + case DeepLinkType.PublicMessageLink: { + const { + domain, post, single, thread, comment, t, + } = queryParams; + return buildPublicMessageLink({ + username: domain, + messageId: post, + isSingle: single, + threadId: thread, + commentId: comment, + mediaTimestamp: t, + }); + } + case DeepLinkType.PrivateMessageLink: { + const { + channel, post, single, thread, comment, t, + } = queryParams; + return buildPrivateMessageLink({ + channelId: channel, + messageId: post, + isSingle: single, + threadId: thread, + commentId: comment, + mediaTimestamp: t, + }); + } + case DeepLinkType.ShareLink: + return buildShareLink({ text: queryParams.text, url: queryParams.url }); + case DeepLinkType.ChatFolderLink: + return buildChatFolderLink({ slug: queryParams.slug }); + default: + break; + } + return undefined; +} + +function handleHttpLink(url: URL) { + if (!ELIGIBLE_HOSTNAMES.has(url.hostname)) { + return undefined; + } + const queryParams = getQueryParams(url); + const pathParams = getPathParams(url); + + const deepLinkType = getHttpDeepLinkType(queryParams, pathParams); + switch (deepLinkType) { + case DeepLinkType.PublicMessageLink: { + const { + single, comment, t, + } = queryParams; + const { + username, + thread, + messageId, + } = pathParams.length === 2 ? { + username: pathParams[0], + thread: queryParams.thread, + messageId: pathParams[1], + } : { + username: pathParams[0], + thread: pathParams[1], + messageId: pathParams[2], + }; + return buildPublicMessageLink({ + username, + messageId, + isSingle: single, + threadId: thread, + commentId: comment, + mediaTimestamp: t, + }); + } + case DeepLinkType.PrivateMessageLink: { + const { + single, comment, t, + } = queryParams; + const { + channelId, + thread, + messageId, + } = pathParams.length === 3 ? { + channelId: pathParams[1], + thread: queryParams.thread, + messageId: pathParams[2], + } : { + channelId: pathParams[1], + thread: pathParams[2], + messageId: pathParams[3], + }; + return buildPrivateMessageLink({ + channelId, + messageId, + isSingle: single, + threadId: thread, + commentId: comment, + mediaTimestamp: t, + }); + } + case DeepLinkType.ShareLink: { + return buildShareLink({ text: queryParams.text, url: queryParams.url }); + } + case DeepLinkType.ChatFolderLink: + return buildChatFolderLink({ slug: pathParams[1] }); + default: + break; + } + return undefined; +} + +function getHttpDeepLinkType( + queryParams: Record, + pathParams: string[], +) { + const len = pathParams.length; + const method = pathParams[0]; + if (len === 1) { + if (method === 'share') { + return DeepLinkType.ShareLink; + } + } else if (len === 2) { + if (method === 'addlist') { + return DeepLinkType.ChatFolderLink; + } + if (isUsernameValid(pathParams[0]) && isNumber(pathParams[1])) { + return DeepLinkType.PublicMessageLink; + } + } else if (len === 3) { + if (method === 'c' && pathParams.slice(1).every(isNumber)) { + return DeepLinkType.PrivateMessageLink; + } + if (isUsernameValid(pathParams[0]) && pathParams.slice(1).every(isNumber)) { + return DeepLinkType.PublicMessageLink; + } + } else if (len === 4) { + if (method === 'c' && pathParams.slice(1).every(isNumber)) { + return DeepLinkType.PrivateMessageLink; + } + } + return DeepLinkType.Unknown; +} + +function getTgDeepLinkType( + queryParams: Record, + pathParams: string[], + method: DeepLinkMethod, +) { + switch (method) { + case 'resolve': { + const { domain, post } = queryParams; + if (domain && post) { + return DeepLinkType.PublicMessageLink; + } + break; + } + case 'privatepost': { + const { channel, post } = queryParams; + if (channel && post) { + return DeepLinkType.PrivateMessageLink; + } + break; + } + case 'msg_url': + return DeepLinkType.ShareLink; + case 'addlist': + return DeepLinkType.ChatFolderLink; + default: + break; + } + return DeepLinkType.Unknown; +} + +function buildShareLink(params: BuilderParams): BuilderReturnType { + const { url, text } = params; + if (!url) { + return undefined; + } + return { + type: DeepLinkType.ShareLink, + url, + text, + }; +} + +function buildPublicMessageLink(params: BuilderParams): BuilderReturnType { + const { + messageId, threadId, commentId, username, isSingle, mediaTimestamp, + } = params; + if (!username || !isUsernameValid(username)) { + return undefined; + } + if (!messageId || !isNumber(messageId)) { + return undefined; + } + if (threadId && !isNumber(threadId)) { + return undefined; + } + if (commentId && !isNumber(commentId)) { + return undefined; + } + return { + type: DeepLinkType.PublicMessageLink, + username, + messageId: Number(messageId), + isSingle: isSingle === '', + threadId: threadId ? Number(threadId) : undefined, + commentId: commentId ? Number(commentId) : undefined, + mediaTimestamp, + }; +} + +function buildPrivateMessageLink(params: BuilderParams): BuilderReturnType { + const { + messageId, threadId, commentId, channelId, isSingle, mediaTimestamp, + } = params; + if (!channelId || !isNumber(channelId)) { + return undefined; + } + if (!messageId || !isNumber(messageId)) { + return undefined; + } + if (threadId && !isNumber(threadId)) { + return undefined; + } + if (commentId && !isNumber(commentId)) { + return undefined; + } + return { + type: DeepLinkType.PrivateMessageLink, + channelId, + messageId: Number(messageId), + isSingle: isSingle === '', + threadId: threadId ? Number(threadId) : undefined, + commentId: commentId ? Number(commentId) : undefined, + mediaTimestamp, + }; +} + +function buildChatFolderLink(params: BuilderParams): BuilderReturnType { + const { + slug, + } = params; + if (!slug) { + return undefined; + } + return { + type: DeepLinkType.ChatFolderLink, + slug, + }; +} + +function isNumber(s: string) { + return /^-?\d+$/.test(s); +} + +function getPathParams(url: URL) { + return url.pathname.split('/').filter(Boolean).map(decodeURI); +} + +function getQueryParams(url: URL) { + return Object.fromEntries(url.searchParams); +} diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index ac1c959cb..251d8ccaf 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -1,14 +1,11 @@ import { getActions } from '../global'; import type { ApiChatType } from '../api/types'; +import type { DeepLinkMethod } from './deepLinkParser'; import { API_CHAT_TYPES } from '../config'; import { IS_SAFARI } from './windowEnvironment'; -type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' | -'setlanguage' | 'addtheme' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' | 'msg' | 'msg_url' | -'invoice' | 'addlist' | 'boost' | 'giftcode'; - export const processDeepLink = (url: string) => { const { protocol, searchParams, pathname, hostname, diff --git a/src/util/username.ts b/src/util/username.ts new file mode 100644 index 000000000..dbc3ca08d --- /dev/null +++ b/src/util/username.ts @@ -0,0 +1,11 @@ +export const MIN_USERNAME_LENGTH = 5; +export const MAX_USERNAME_LENGTH = 32; +export const USERNAME_REGEX = /^\D([a-zA-Z0-9_]+)$/; + +export function isUsernameValid(username: string) { + return username.length === 0 || ( + username.length >= MIN_USERNAME_LENGTH + && username.length <= MAX_USERNAME_LENGTH + && USERNAME_REGEX.test(username) + ); +}