Introduce deep link parser (#4038)

This commit is contained in:
Alexander Zinchuk 2023-12-04 14:39:40 +01:00
parent 761acbf073
commit 4571745654
4 changed files with 356 additions and 15 deletions

View File

@ -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<OwnProps> = ({
currentUsername,
asLink,

341
src/util/deepLinkParser.ts Normal file
View File

@ -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<T extends DeepLink> = Record<keyof Omit<T, 'type'>, string>;
type BuilderReturnType<T extends DeepLink> = 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<string, string>,
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<string, string>,
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<ShareLink>): BuilderReturnType<ShareLink> {
const { url, text } = params;
if (!url) {
return undefined;
}
return {
type: DeepLinkType.ShareLink,
url,
text,
};
}
function buildPublicMessageLink(params: BuilderParams<PublicMessageLink>): BuilderReturnType<PublicMessageLink> {
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<PrivateMessageLink>): BuilderReturnType<PrivateMessageLink> {
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<ChatFolderLink>): BuilderReturnType<ChatFolderLink> {
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);
}

View File

@ -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,

11
src/util/username.ts Normal file
View File

@ -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)
);
}