From b9ed982170e4ed7596a734d17fe0e4aac86209af Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 19 Apr 2024 13:38:21 +0400 Subject: [PATCH] Support chat links (#4469) --- src/api/gramjs/apiBuilders/misc.ts | 11 ++++- src/api/gramjs/methods/account.ts | 23 ++++++++++ src/api/gramjs/methods/index.ts | 4 +- src/api/types/chats.ts | 7 ++- src/components/common/Composer.tsx | 19 ++++---- src/components/common/LinkField.tsx | 2 +- src/global/actions/api/bots.ts | 4 +- src/global/actions/api/chats.ts | 68 ++++++++++++++++++++++++----- src/global/selectors/chats.ts | 2 +- src/global/types.ts | 9 +++- src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/util/deepLinkParser.ts | 49 ++++++++++++++++++--- src/util/deeplink.ts | 26 ++++++++--- 14 files changed, 183 insertions(+), 43 deletions(-) diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 0bc5c0be5..d11f77202 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -2,6 +2,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiPrivacyKey } from '../../../types'; import type { + ApiChatLink, ApiConfig, ApiCountry, ApiLangString, ApiPeerColors, ApiSession, ApiTimezone, ApiUrlAuthResult, ApiWallpaper, ApiWebSession, @@ -11,7 +12,7 @@ import { buildCollectionByCallback, omit, pick } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; import { addUserToLocalDb } from '../helpers'; import { omitVirtualClassFields } from './helpers'; -import { buildApiDocument } from './messageContent'; +import { buildApiDocument, buildMessageTextContent } from './messageContent'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiReaction } from './reactions'; import { buildApiUser } from './users'; @@ -261,3 +262,11 @@ export function buildApiTimezone(timezone: GramJs.TypeTimezone): ApiTimezone { utcOffset, }; } + +export function buildApiChatLink(data: GramJs.account.ResolvedBusinessChatLinks): ApiChatLink { + const chatId = getApiChatIdFromMtpPeer(data.peer); + return { + chatId, + text: buildMessageTextContent(data.message, data.entities), + }; +} diff --git a/src/api/gramjs/methods/account.ts b/src/api/gramjs/methods/account.ts index b7856610a..f895672c6 100644 --- a/src/api/gramjs/methods/account.ts +++ b/src/api/gramjs/methods/account.ts @@ -5,6 +5,9 @@ import type { ApiPeer, ApiPhoto, ApiReportReason, } from '../../types'; +import { buildApiChatFromPreview } from '../apiBuilders/chats'; +import { buildApiChatLink } from '../apiBuilders/misc'; +import { buildApiUser } from '../apiBuilders/users'; import { buildInputPeer, buildInputPhoto, buildInputReportReason } from '../gramjsBuilders'; import { invokeRequest } from './client'; @@ -71,3 +74,23 @@ export async function changeSessionTtl({ return result; } + +export async function resolveBusinessChatLink({ slug } : { slug: string }) { + const result = await invokeRequest(new GramJs.account.ResolveBusinessChatLink({ + slug, + }), { + shouldIgnoreErrors: true, + }); + if (!result) return undefined; + + const users = result.users.map(buildApiUser).filter(Boolean); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); + + const chatLink = buildApiChatLink(result); + + return { + users, + chats, + chatLink, + }; +} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 0be1d0775..10b8e0801 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -3,9 +3,7 @@ export { setForceHttpTransport, setShouldDebugExportedSenders, setAllowHttpTransport, requestChannelDifference, } from './client'; -export { - reportPeer, reportProfilePhoto, changeSessionSettings, changeSessionTtl, -} from './account'; +export * from './account'; export { provideAuthPhoneNumber, provideAuthCode, provideAuthPassword, provideAuthRegistration, restartAuth, restartAuthWithQr, diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 0d7910453..ede1fe528 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -1,7 +1,7 @@ import type { ThreadId } from '../../types'; import type { ApiBotCommand } from './bots'; import type { - ApiChatReactions, ApiPhoto, ApiStickerSet, + ApiChatReactions, ApiFormattedText, ApiPhoto, ApiStickerSet, } from './messages'; import type { ApiChatInviteImporter } from './misc'; import type { @@ -284,3 +284,8 @@ export interface ApiMissingInvitedUser { isRequiringPremiumToInvite?: boolean; isRequiringPremiumToMessage?: boolean; } + +export interface ApiChatLink { + chatId: string; + text: ApiFormattedText; +} diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 62227c337..9a5386e49 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -76,8 +76,8 @@ import { selectIsRightColumnShown, selectNewestMessageWithBotKeyboardButtons, selectPeerStory, + selectRequestedDraft, selectRequestedDraftFiles, - selectRequestedDraftText, selectScheduledIds, selectTabState, selectTheme, @@ -99,7 +99,6 @@ import { IS_IOS, IS_VOICE_RECORDING_SUPPORTED } from '../../util/windowEnvironme import windowSize from '../../util/windowSize'; import applyIosAutoCapitalizationFix from '../middle/composer/helpers/applyIosAutoCapitalizationFix'; import buildAttachment, { prepareAttachmentsToSend } from '../middle/composer/helpers/buildAttachment'; -import { escapeHtml } from '../middle/composer/helpers/cleanHtml'; import { buildCustomEmojiHtml } from '../middle/composer/helpers/customEmoji'; import { isSelectionInsideInput } from '../middle/composer/helpers/selection'; import { getPeerColorClass } from './helpers/peerColor'; @@ -228,7 +227,7 @@ type StateProps = sendAsChat?: ApiChat; sendAsId?: string; editingDraft?: ApiFormattedText; - requestedDraftText?: string; + requestedDraft?: ApiFormattedText; requestedDraftFiles?: File[]; attachBots: GlobalState['attachMenu']['bots']; attachMenuPeerType?: ApiAttachMenuPeerType; @@ -332,7 +331,7 @@ const Composer: FC = ({ sendAsChat, sendAsId, editingDraft, - requestedDraftText, + requestedDraft, requestedDraftFiles, botMenuButton, attachBots, @@ -691,7 +690,7 @@ const Composer: FC = ({ getHtml, setHtml, editedMessage: editingMessage, - isDisabled: isInStoryViewer, + isDisabled: isInStoryViewer || Boolean(requestedDraft), }); const resetComposer = useLastCallback((shouldPreserveInput = false) => { @@ -1080,8 +1079,8 @@ const Composer: FC = ({ }, [contentToBeScheduled, currentMessageList, handleMessageSchedule, requestCalendar]); useEffect(() => { - if (requestedDraftText) { - setHtml(escapeHtml(requestedDraftText)); + if (requestedDraft) { + insertFormattedTextAndUpdateCursor(requestedDraft); resetOpenChatWithDraft(); requestNextMutation(() => { @@ -1089,7 +1088,7 @@ const Composer: FC = ({ focusEditableElement(messageInput, true); }); } - }, [editableInputId, requestedDraftText, resetOpenChatWithDraft, setHtml]); + }, [editableInputId, requestedDraft, resetOpenChatWithDraft, setHtml]); useEffect(() => { if (requestedDraftFiles?.length) { @@ -1987,7 +1986,7 @@ export default memo(withGlobal( ); const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; - const requestedDraftText = selectRequestedDraftText(global, chatId); + const requestedDraft = selectRequestedDraft(global, chatId); const requestedDraftFiles = selectRequestedDraftFiles(global, chatId); const tabState = selectTabState(global); @@ -2064,7 +2063,7 @@ export default memo(withGlobal( sendAsChat, sendAsId, editingDraft, - requestedDraftText, + requestedDraft, requestedDraftFiles, attachBots: global.attachMenu.bots, attachMenuPeerType: selectChatType(global, chatId), diff --git a/src/components/common/LinkField.tsx b/src/components/common/LinkField.tsx index 7b3c51af6..e932ba3a1 100644 --- a/src/components/common/LinkField.tsx +++ b/src/components/common/LinkField.tsx @@ -53,7 +53,7 @@ const InviteLink: FC = ({ }); const handleShare = useLastCallback(() => { - openChatWithDraft({ text: link }); + openChatWithDraft({ text: { text: link } }); }); const PrimaryLinkMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index e9e0c3d98..a305967b3 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -341,7 +341,9 @@ addActionHandler('switchBotInline', (global, actions, payload): ActionReturnType } actions.openChatWithDraft({ - text: `@${botSender.usernames![0].username} ${query}`, + text: { + text: `@${botSender.usernames![0].username} ${query}`, + }, chatId: isSamePeer ? chat.id : undefined, filter, tabId, diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 7b22e327f..b9dad48b1 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -1235,7 +1235,7 @@ addActionHandler('openChatByInvite', async (global, actions, payload): Promise => { const { - phoneNumber, startAttach, attach, tabId = getCurrentTabId(), + phoneNumber, startAttach, attach, text, tabId = getCurrentTabId(), } = payload!; // Open temporary empty chat to make the click response feel faster @@ -1251,7 +1251,11 @@ addActionHandler('openChatByPhoneNumber', async (global, actions, payload): Prom return; } - actions.openChat({ id: chat.id, tabId }); + if (text) { + actions.openChatWithDraft({ chatId: chat.id, text: { text }, tabId }); + } else { + actions.openChat({ id: chat.id, tabId }); + } if (attach) { global = getGlobal(); @@ -1317,6 +1321,7 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp phoneNumber: part1.substr(1, part1.length - 1), startAttach: params.startattach, attach: params.attach, + text: params.text, tabId, }); return; @@ -1481,7 +1486,7 @@ addActionHandler('acceptInviteConfirmation', async (global, actions, payload): P addActionHandler('openChatByUsername', async (global, actions, payload): Promise => { const { - username, messageId, commentId, startParam, startAttach, attach, threadId, originalParts, startApp, + username, messageId, commentId, startParam, startAttach, attach, threadId, originalParts, startApp, text, tabId = getCurrentTabId(), } = payload!; @@ -1499,7 +1504,15 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise } if (!isWebApp) { await openChatByUsername( - global, actions, username, threadId, messageId, startParam, startAttach, attach, tabId, + global, actions, { + username, + threadId, + channelPostId: messageId, + startParam, + startAttach, + attach, + text, + }, tabId, ); return; } @@ -2643,6 +2656,31 @@ addActionHandler('toggleChannelRecommendations', (global, actions, payload): Act setGlobal(global); }); +addActionHandler('resolveBusinessChatLink', async (global, actions, payload): Promise => { + const { slug, tabId = getCurrentTabId() } = payload; + const result = await callApi('resolveBusinessChatLink', { slug }); + if (!result) { + actions.showNotification({ + message: langProvider.translate('BusinessLink.ErrorExpired'), + tabId, + }); + return; + } + + const { users, chats, chatLink } = result; + + global = getGlobal(); + global = addUsers(global, buildCollectionByKey(users, 'id')); + global = addChats(global, buildCollectionByKey(chats, 'id')); + setGlobal(global); + + actions.openChatWithDraft({ + chatId: chatLink.chatId, + text: chatLink.text, + tabId, + }); +}); + async function loadChats( listType: ChatListType, offsetId?: string, @@ -2944,14 +2982,20 @@ async function getAttachBotOrNotify( async function openChatByUsername( global: T, actions: RequiredGlobalActions, - username: string, - threadId?: ThreadId, - channelPostId?: number, - startParam?: string, - startAttach?: string, - attach?: string, + params: { + username: string; + threadId?: ThreadId; + channelPostId?: number; + startParam?: string; + startAttach?: string; + attach?: string; + text?: string; + }, ...[tabId = getCurrentTabId()]: TabArgs ) { + const { + username, threadId, channelPostId, startParam, startAttach, attach, text, + } = params; global = getGlobal(); const currentChat = selectCurrentChat(global, tabId); @@ -3004,6 +3048,10 @@ async function openChatByUsername( global = getGlobal(); openAttachMenuFromLink(global, actions, chat.id, attach, startAttach, tabId); } + + if (text) { + actions.openChatWithDraft({ chatId: chat.id, text: { text }, tabId }); + } } async function openAttachMenuFromLink( diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index 3222f2f7f..6b0eaf55a 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -221,7 +221,7 @@ export function selectSendAs(global: T, chatId: string) { return selectUser(global, id) || selectChat(global, id); } -export function selectRequestedDraftText( +export function selectRequestedDraft( global: T, chatId: string, ...[tabId = getCurrentTabId()]: TabArgs ) { diff --git a/src/global/types.ts b/src/global/types.ts index 769f842ec..c071eae14 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -559,7 +559,7 @@ export type TabState = { requestedDraft?: { chatId?: string; - text: string; + text: ApiFormattedText; files?: File[]; filter?: ApiChatType[]; }; @@ -1323,6 +1323,7 @@ export interface ActionPayloads { phoneNumber: string; startAttach?: string | boolean; attach?: string; + text?: string; } & WithTabId; openChatByInvite: { hash: string; @@ -1500,6 +1501,9 @@ export interface ActionPayloads { openTelegramLink: { url: string; } & WithTabId; + resolveBusinessChatLink: { + slug: string; + } & WithTabId; openChatByUsername: { username: string; threadId?: ThreadId; @@ -1509,6 +1513,7 @@ export interface ActionPayloads { startAttach?: string; attach?: string; startApp?: string; + text?: string; originalParts?: string[]; } & WithTabId; processBoostParameters: { @@ -1967,7 +1972,7 @@ export interface ActionPayloads { openChatWithDraft: { chatId?: string; threadId?: ThreadId; - text: string; + text: ApiFormattedText; files?: File[]; filter?: ApiChatType[]; } & WithTabId; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 3d29cd904..e993ac270 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1344,6 +1344,7 @@ account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; account.reorderUsernames#ef500eab order:Vector = Bool; account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; users.getUsers#d91a548 id:Vector = Vector; users.getFullUser#b60f5918 id:InputUser = users.UserFull; contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 2e7fa6dcf..3d03916b2 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -58,6 +58,7 @@ "account.getRecentEmojiStatuses", "account.reorderUsernames", "account.toggleUsername", + "account.resolveBusinessChatLink", "users.getUsers", "users.getFullUser", "contacts.getContacts", diff --git a/src/util/deepLinkParser.ts b/src/util/deepLinkParser.ts index 8d8fddcb3..c5b73d6cc 100644 --- a/src/util/deepLinkParser.ts +++ b/src/util/deepLinkParser.ts @@ -6,7 +6,7 @@ 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'; +'invoice' | 'addlist' | 'boost' | 'giftcode' | 'message'; interface PublicMessageLink { type: 'publicMessageLink'; @@ -59,7 +59,13 @@ interface TelegramPassportLink { interface PublicUsernameOrBotLink { type: 'publicUsernameOrBotLink'; username: string; - parameter?: string; + start?: string; + text?: string; +} + +interface BusinessChatLink { + type: 'businessChatLink'; + slug: string; } type DeepLink = @@ -69,7 +75,8 @@ type DeepLink = PrivateMessageLink | ShareLink | ChatFolderLink | - PublicUsernameOrBotLink; + PublicUsernameOrBotLink | + BusinessChatLink; type BuilderParams = Record, string | undefined>; type BuilderReturnType = T | undefined; @@ -173,8 +180,11 @@ function parseTgLink(url: URL) { case 'publicUsernameOrBotLink': return buildPublicUsernameOrBotLink({ username: queryParams.domain, - parameter: queryParams.start, + start: queryParams.start, + text: queryParams.text, }); + case 'businessChatLink': + return buildBusinessChatLink({ slug: queryParams.slug }); default: break; } @@ -254,8 +264,11 @@ function parseHttpLink(url: URL) { case 'publicUsernameOrBotLink': return buildPublicUsernameOrBotLink({ username: pathParams[0], - parameter: queryParams.start, + start: queryParams.start, + text: queryParams.text, }); + case 'businessChatLink': + return buildBusinessChatLink({ slug: pathParams[1] }); default: break; } @@ -285,6 +298,9 @@ function getHttpDeepLinkType( if (isUsernameValid(pathParams[0]) && isNumber(pathParams[1])) { return 'publicMessageLink'; } + if (method === 'm') { + return 'businessChatLink'; + } } else if (len === 3) { if (method === 'c' && pathParams.slice(1).every(isNumber)) { return 'privateMessageLink'; @@ -337,6 +353,8 @@ function getTgDeepLinkType( return 'loginCodeLink'; case 'passport': return 'telegramPassportLink'; + case 'message': + return 'businessChatLink'; default: break; } @@ -467,7 +485,8 @@ function buildPublicUsernameOrBotLink( ): BuilderReturnType { const { username, - parameter, + start, + text, } = params; if (!username) { return undefined; @@ -478,7 +497,23 @@ function buildPublicUsernameOrBotLink( return { type: 'publicUsernameOrBotLink', username, - parameter, + start, + text, + }; +} + +function buildBusinessChatLink(params: BuilderParams): BuilderReturnType { + const { + slug, + } = params; + + if (!slug) { + return undefined; + } + + return { + type: 'businessChatLink', + slug, }; } diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index f519174da..8f7d17a99 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -1,6 +1,6 @@ import { getActions } from '../global'; -import type { ApiChatType } from '../api/types'; +import type { ApiChatType, ApiFormattedText } from '../api/types'; import type { DeepLinkMethod, PrivateMessageLink } from './deepLinkParser'; import { API_CHAT_TYPES, RE_TG_LINK } from '../config'; @@ -20,7 +20,13 @@ export const processDeepLink = (url: string): boolean => { case 'publicUsernameOrBotLink': actions.openChatByUsername({ username: parsedLink.username, - startParam: parsedLink.parameter, + startParam: parsedLink.start, + text: parsedLink.text, + }); + return true; + case 'businessChatLink': + actions.resolveBusinessChatLink({ + slug: parsedLink.slug, }); return true; default: @@ -61,7 +67,7 @@ export const processDeepLink = (url: string): boolean => { case 'resolve': { const { domain, phone, post, comment, voicechat, livestream, start, startattach, attach, thread, topic, - appname, startapp, story, + appname, startapp, story, text, } = params; const hasStartAttach = params.hasOwnProperty('startattach'); @@ -76,6 +82,7 @@ export const processDeepLink = (url: string): boolean => { username: domain, startApp: startapp, originalParts: [domain, appname], + text, }); } else if ((hasStartAttach && choose) || (!appname && hasStartApp)) { processAttachBotParameters({ @@ -91,7 +98,12 @@ export const processDeepLink = (url: string): boolean => { } else if (hasBoost) { processBoostParameters({ usernameOrId: domain }); } else if (phone) { - openChatByPhoneNumber({ phoneNumber: phone, startAttach: startattach, attach }); + openChatByPhoneNumber({ + phoneNumber: phone, + startAttach: startattach, + attach, + text, + }); } else if (story) { openStoryViewerByUsername({ username: domain, storyId: Number(story) }); } else { @@ -180,8 +192,10 @@ export function parseChooseParameter(choose?: string) { return types.filter((type): type is ApiChatType => API_CHAT_TYPES.includes(type as ApiChatType)); } -export function formatShareText(url?: string, text?: string, title?: string): string { - return [url, title, text].filter(Boolean).join('\n'); +export function formatShareText(url?: string, text?: string, title?: string): ApiFormattedText { + return { + text: [url, title, text].filter(Boolean).join('\n'), + }; } function handlePrivateMessageLink(link: PrivateMessageLink, actions: ReturnType) {