Introduce deep link parser (#4038)
This commit is contained in:
parent
761acbf073
commit
4571745654
@ -6,6 +6,9 @@ import { getActions } from '../../global';
|
|||||||
|
|
||||||
import { TME_LINK_PREFIX } from '../../config';
|
import { TME_LINK_PREFIX } from '../../config';
|
||||||
import { debounce } from '../../util/schedulers';
|
import { debounce } from '../../util/schedulers';
|
||||||
|
import {
|
||||||
|
isUsernameValid, MAX_USERNAME_LENGTH, MIN_USERNAME_LENGTH, USERNAME_REGEX,
|
||||||
|
} from '../../util/username';
|
||||||
|
|
||||||
import useLang from '../../hooks/useLang';
|
import useLang from '../../hooks/useLang';
|
||||||
import usePrevious from '../../hooks/usePrevious';
|
import usePrevious from '../../hooks/usePrevious';
|
||||||
@ -21,21 +24,10 @@ type OwnProps = {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_USERNAME_LENGTH = 5;
|
|
||||||
const MAX_USERNAME_LENGTH = 32;
|
|
||||||
const LINK_PREFIX_REGEX = /https:\/\/t\.me\/?/i;
|
const LINK_PREFIX_REGEX = /https:\/\/t\.me\/?/i;
|
||||||
const USERNAME_REGEX = /^\D([a-zA-Z0-9_]+)$/;
|
|
||||||
|
|
||||||
const runDebouncedForCheckUsername = debounce((cb) => cb(), 250, false);
|
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> = ({
|
const UsernameInput: FC<OwnProps> = ({
|
||||||
currentUsername,
|
currentUsername,
|
||||||
asLink,
|
asLink,
|
||||||
|
|||||||
341
src/util/deepLinkParser.ts
Normal file
341
src/util/deepLinkParser.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -1,14 +1,11 @@
|
|||||||
import { getActions } from '../global';
|
import { getActions } from '../global';
|
||||||
|
|
||||||
import type { ApiChatType } from '../api/types';
|
import type { ApiChatType } from '../api/types';
|
||||||
|
import type { DeepLinkMethod } from './deepLinkParser';
|
||||||
|
|
||||||
import { API_CHAT_TYPES } from '../config';
|
import { API_CHAT_TYPES } from '../config';
|
||||||
import { IS_SAFARI } from './windowEnvironment';
|
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) => {
|
export const processDeepLink = (url: string) => {
|
||||||
const {
|
const {
|
||||||
protocol, searchParams, pathname, hostname,
|
protocol, searchParams, pathname, hostname,
|
||||||
|
|||||||
11
src/util/username.ts
Normal file
11
src/util/username.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user