diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 278ebf1fd..7920ea4e4 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -166,7 +166,10 @@ export function buildJson(json: GramJs.TypeJSONValue): any { export function buildApiUrlAuthResult(result: GramJs.TypeUrlAuthResult): ApiUrlAuthResult | undefined { if (result instanceof GramJs.UrlAuthResultRequest) { - const { bot, domain, requestWriteAccess } = result; + const { + bot, domain, requestWriteAccess, requestPhoneNumber, browser, platform, ip, region, matchCodes, + matchCodesFirst, userIdHint, + } = result; const user = buildApiUser(bot); if (!user) return undefined; @@ -177,6 +180,14 @@ export function buildApiUrlAuthResult(result: GramJs.TypeUrlAuthResult): ApiUrlA domain, shouldRequestWriteAccess: requestWriteAccess, bot: user, + shouldRequestPhoneNumber: requestPhoneNumber, + browser, + platform, + ip, + region, + matchCodes, + matchCodesFirst, + userIdHint: userIdHint?.toString(), }; } diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index a10a587c6..2697bd634 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -1,4 +1,5 @@ import { Api as GramJs } from '../../../lib/gramjs'; +import { RPCError } from '../../../lib/gramjs/errors'; import { generateRandomBigInt } from '../../../lib/gramjs/Helpers'; import type { @@ -8,6 +9,7 @@ import type { ApiInputMessageReplyInfo, ApiPeer, ApiThemeParameters, + ApiUrlAuthResult, ApiUser, } from '../../types'; @@ -450,23 +452,11 @@ export async function requestBotUrlAuth({ buttonId: number; messageId: number; }) { - const result = await invokeRequest(new GramJs.messages.RequestUrlAuth({ + return invokeUrlAuthRequest(new GramJs.messages.RequestUrlAuth({ peer: buildInputPeer(chat.id, chat.accessHash), buttonId, msgId: messageId, })); - - if (!result) return undefined; - - const authResult = buildApiUrlAuthResult(result); - if (authResult?.type === 'request') { - sendApiUpdate({ - '@type': 'updateUser', - id: authResult.bot.id, - user: authResult.bot, - }); - } - return authResult; } export async function acceptBotUrlAuth({ @@ -474,67 +464,71 @@ export async function acceptBotUrlAuth({ messageId, buttonId, isWriteAllowed, + wasPhoneShared, + matchCode, }: { chat: ApiChat; messageId: number; buttonId: number; isWriteAllowed?: boolean; + wasPhoneShared?: boolean; + matchCode?: string; }) { - const result = await invokeRequest(new GramJs.messages.AcceptUrlAuth({ + return invokeUrlAuthRequest(new GramJs.messages.AcceptUrlAuth({ peer: buildInputPeer(chat.id, chat.accessHash), msgId: messageId, buttonId, writeAllowed: isWriteAllowed || undefined, + sharePhoneNumber: wasPhoneShared || undefined, + matchCode: matchCode || undefined, })); - - if (!result) return undefined; - - const authResult = buildApiUrlAuthResult(result); - if (authResult?.type === 'request') { - sendApiUpdate({ - '@type': 'updateUser', - id: authResult.bot.id, - user: authResult.bot, - }); - } - return authResult; } export async function requestLinkUrlAuth({ url }: { url: string }) { - const result = await invokeRequest(new GramJs.messages.RequestUrlAuth({ + return invokeUrlAuthRequest(new GramJs.messages.RequestUrlAuth({ url, })); - - if (!result) return undefined; - - const authResult = buildApiUrlAuthResult(result); - if (authResult?.type === 'request') { - sendApiUpdate({ - '@type': 'updateUser', - id: authResult.bot.id, - user: authResult.bot, - }); - } - return authResult; } -export async function acceptLinkUrlAuth({ url, isWriteAllowed }: { url: string; isWriteAllowed?: boolean }) { - const result = await invokeRequest(new GramJs.messages.AcceptUrlAuth({ +export async function acceptLinkUrlAuth({ + url, isWriteAllowed, wasPhoneShared, matchCode, +}: { + url: string; + isWriteAllowed?: boolean; + wasPhoneShared?: boolean; + matchCode?: string; +}) { + return invokeUrlAuthRequest(new GramJs.messages.AcceptUrlAuth({ url, writeAllowed: isWriteAllowed || undefined, + sharePhoneNumber: wasPhoneShared || undefined, + matchCode: matchCode || undefined, })); +} - if (!result) return undefined; - - const authResult = buildApiUrlAuthResult(result); - if (authResult?.type === 'request') { - sendApiUpdate({ - '@type': 'updateUser', - id: authResult.bot.id, - user: authResult.bot, +export async function checkUrlAuthMatchCode({ url, matchCode }: { url: string; matchCode: string }) { + try { + const result = await invokeRequest(new GramJs.messages.CheckUrlAuthMatchCode({ + url, + matchCode, + }), { + shouldThrow: true, }); + if (!result) return { type: 'unmatched' }; + + return { type: 'matched' }; + } catch (err) { + if (err instanceof RPCError && err.errorMessage === 'URL_EXPIRED') { + return { type: 'expired' }; + } + throw err; } - return authResult; +} + +export async function declineUrlAuth({ url }: { url: string }) { + return invokeRequest(new GramJs.messages.DeclineUrlAuth({ url }), { + shouldReturnTrue: true, + }); } export function fetchBotCanSendMessage({ bot }: { bot: ApiUser }) { @@ -720,3 +714,27 @@ export async function fetchBotsRecommendations({ user }: { user: ApiChat }) { count: result instanceof GramJs.users.UsersSlice ? result.count : similarBots.length, }; } + +async function invokeUrlAuthRequest( + request: GramJs.messages.RequestUrlAuth | GramJs.messages.AcceptUrlAuth, +): Promise { + try { + const result = await invokeRequest(request, { shouldThrow: true }); + if (!result) return undefined; + + const authResult = buildApiUrlAuthResult(result); + if (authResult?.type === 'request') { + sendApiUpdate({ + '@type': 'updateUser', + id: authResult.bot.id, + user: authResult.bot, + }); + } + return authResult; + } catch (err) { + if (err instanceof RPCError && err.errorMessage === 'URL_EXPIRED') { + return { type: 'expired' }; + } + throw err; + } +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index fffe31da7..c36848218 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1106,13 +1106,19 @@ export type ApiSearchPostsFlood = { starsAmount: number; }; -export type LinkContext = { +export type LinkContextMessage = { type: 'message'; threadId?: ThreadId; chatId: string; messageId: number; }; +export type LinkContextInner = { + type: 'inner'; +}; + +export type LinkContext = LinkContextMessage | LinkContextInner; + export interface ApiTopic { id: number; isClosed?: boolean; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 8942a153b..525876325 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -348,11 +348,19 @@ export interface ApiEmojiInteraction { timestamps: number[]; } -type ApiUrlAuthResultRequest = { +export type ApiUrlAuthResultRequest = { type: 'request'; bot: ApiUser; domain: string; shouldRequestWriteAccess?: boolean; + shouldRequestPhoneNumber?: boolean; + browser?: string; + platform?: string; + ip?: string; + region?: string; + matchCodes?: string[]; + matchCodesFirst?: boolean; + userIdHint?: string; }; type ApiUrlAuthResultAccepted = { @@ -360,11 +368,19 @@ type ApiUrlAuthResultAccepted = { url?: string; }; +type ApiUrlAuthResultExpired = { + type: 'expired'; +}; + type ApiUrlAuthResultDefault = { type: 'default'; }; -export type ApiUrlAuthResult = ApiUrlAuthResultRequest | ApiUrlAuthResultAccepted | ApiUrlAuthResultDefault; +export type ApiUrlAuthResult = + ApiUrlAuthResultRequest + | ApiUrlAuthResultAccepted + | ApiUrlAuthResultExpired + | ApiUrlAuthResultDefault; export interface ApiCollectibleInfo { amount: number; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 55aff40bd..0c15c2dfc 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -611,8 +611,19 @@ "OpenUrlTitle" = "Open Link"; "OpenUrlText" = "Do you want to open **{url}**?"; "OpenUrlConfirm" = "Open"; -"ConversationOpenBotLinkLogin" = "Log in to **{url}** as {user}"; -"ConversationOpenBotLinkAllowMessages" = "Allow **{bot}** to send me messages"; +"BotAuthTitle" = "Log in to {url}"; +"BotAuthSiteSubtitle" = "This site will receive your **name**, **username** and **profile photo**."; +"BotAuthAllowMessages" = "Allow Messages"; +"BotAuthAllowMessagesInfo" = "This will allow **{bot}** to message you."; +"BotAuthInfo" = "This login attempt came from the device above."; +"BotAuthDevice" = "Device"; +"BotAuthSelectEmoji" = "Tap the emoji shown on your other device"; +"BotAuthPhoneNumber" = "Phone Number"; +"BotAuthPhoneNumberText" = "**{domain}** wants to access your phone number **{phone}**."; +"BotAuthPhoneNumberQuestion" = "Allow access?"; +"BotAuthPhoneNumberAccept" = "Allow"; +"BotAuthPhoneNumberDeny" = "Deny"; +"BotAuthLogin" = "Log in"; "BotWebViewOpenBot" = "Open Bot"; "BotChatMiniAppOpen" = "Open"; "WebAppReloadPage" = "Reload Page"; @@ -725,6 +736,7 @@ "ErrorPasswordChanged" = "Password has been changed, please try again"; "ErrorPasswordMissing" = "You must set 2FA password to use this feature"; "ErrorPasskeyUnknown" = "This passkey is not assigned to any account"; +"ErrorUrlExpired" = "This link has expired"; "ErrorUnspecified" = "Error"; "NoStickers" = "No stickers yet"; "ClearRecentEmoji" = "Clear recent emoji?"; @@ -2749,6 +2761,7 @@ "ChatListAuctionView" = "View"; "BotAuthSuccessTitle" = "Login Successful"; "BotAuthSuccessText" = "You've successfully logged into **{url}**"; +"BotAuthSuccessTextNoPhone" = "You're now logged in to **{url}**, but you didn't grant access to your phone number."; "GiftPreviewSelectedTraits" = "Selected traits"; "GiftPreviewCountModels_one" = "This collection features **{count}** unique model"; "GiftPreviewCountModels_other" = "This collection features **{count}** unique models"; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index a4949ac0b..3dd90f4f1 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -426,7 +426,7 @@ const Main = ({ const parsedInitialLocationHash = parseInitialLocationHash(); if (parsedInitialLocationHash?.tgaddr) { - processDeepLink(decodeURIComponent(parsedInitialLocationHash.tgaddr)); + processDeepLink(decodeURIComponent(parsedInitialLocationHash.tgaddr), { type: 'inner' }); } }, [isSynced]); @@ -434,7 +434,7 @@ const Main = ({ try { const url = event.payload || ''; const decodedUrl = decodeURIComponent(url); - processDeepLink(decodedUrl); + processDeepLink(decodedUrl, { type: 'inner' }); } catch (e) { if (DEBUG) { // eslint-disable-next-line no-console diff --git a/src/components/modals/urlAuth/UrlAuthModal.module.scss b/src/components/modals/urlAuth/UrlAuthModal.module.scss new file mode 100644 index 000000000..9c392fe1b --- /dev/null +++ b/src/components/modals/urlAuth/UrlAuthModal.module.scss @@ -0,0 +1,71 @@ +.content { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.center { + align-self: center; +} + +.textCenter { + text-align: center; +} + +.title { + margin-bottom: 0; +} + +.actions { + display: flex; + gap: 1rem; + margin-top: 0.5rem; +} + +.infoCard { + display: flex; + flex-direction: column; + + min-width: 12rem; + padding: 0.5rem; + border-radius: 1rem; + + background-color: var(--color-background-secondary); +} + +.infoLabel, .note { + font-size: 0.9375rem; + color: var(--color-text-secondary); +} + +.allowMessages { + margin-top: 0.5rem; +} + +.infoValue { + font-size: 0.9375rem; + color: var(--color-text); +} + +.matchCodes { + display: flex; + gap: 0.75rem; + justify-content: space-between; + margin-bottom: 1rem; +} + +.matchCodeButton { + --custom-emoji-size: 2rem; + + font-size: 2rem; +} + +.footnote { + font-size: 0.9375rem; + color: var(--color-text-secondary); + text-align: center; +} + +.cancelButton { + margin-top: 0.75rem; +} diff --git a/src/components/modals/urlAuth/UrlAuthModal.tsx b/src/components/modals/urlAuth/UrlAuthModal.tsx index baa3b15a5..5bd799014 100644 --- a/src/components/modals/urlAuth/UrlAuthModal.tsx +++ b/src/components/modals/urlAuth/UrlAuthModal.tsx @@ -1,121 +1,366 @@ -import type { FC } from '../../../lib/teact/teact'; import { memo, useCallback, useEffect, useState, } from '../../../lib/teact/teact'; -import { getActions, getGlobal, withGlobal } from '../../../global'; +import { getActions, withGlobal } from '../../../global'; import type { ApiUser } from '../../../api/types'; -import type { TabState } from '../../../global/types'; +import type { GlobalState, TabState } from '../../../global/types'; import { getUserFullName } from '../../../global/helpers'; -import { selectUser } from '../../../global/selectors'; -import { ensureProtocol } from '../../../util/browser/url'; +import { selectAnimatedEmoji, selectUser } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { formatPhoneNumber } from '../../../util/phoneNumber'; +import { REM } from '../../common/helpers/mediaDimensions'; import renderText from '../../common/helpers/renderText'; +import { useShallowSelector } from '../../../hooks/data/useSelector'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; -import useOldLang from '../../../hooks/useOldLang'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; -import Checkbox from '../../ui/Checkbox'; -import ConfirmDialog from '../../ui/ConfirmDialog'; +import Avatar from '../../common/Avatar'; +import CustomEmoji from '../../common/CustomEmoji'; +import SafeLink from '../../common/SafeLink'; +import Button from '../../ui/Button'; +import ListItem from '../../ui/ListItem'; +import Modal from '../../ui/Modal'; +import Switcher from '../../ui/Switcher'; + +import styles from './UrlAuthModal.module.scss'; export type OwnProps = { modal?: TabState['urlAuth']; }; type StateProps = { + bot?: ApiUser; currentUser?: ApiUser; }; -const UrlAuthModal: FC = ({ - modal, currentUser, -}) => { - const { closeUrlAuthModal, acceptBotUrlAuth, acceptLinkUrlAuth } = getActions(); - const [isLoginChecked, setLoginChecked] = useState(true); - const [isWriteAccessChecked, setWriteAccessChecked] = useState(true); - const currentAuth = useCurrentOrPrev(modal, false); - const { domain, botId, shouldRequestWriteAccess } = currentAuth?.request || {}; - const bot = botId ? getGlobal().users.byId[botId] : undefined; +type AcceptParams = { + wasPhoneShared?: boolean; + matchCode?: string; +}; - const lang = useOldLang(); +type DialogState = 'closed' | 'match-confirm' | 'phone'; +const MATCH_CODE_EMOJI_SIZE = 2 * REM; - const handleOpen = useCallback(() => { - if (modal?.url && isLoginChecked) { - const acceptAction = modal.button ? acceptBotUrlAuth : acceptLinkUrlAuth; - acceptAction({ - isWriteAllowed: isWriteAccessChecked, - }); - } else if (currentAuth?.url) { - window.open(ensureProtocol(currentAuth.url), '_blank', 'noopener'); - } - closeUrlAuthModal(); - }, [ - modal, isLoginChecked, closeUrlAuthModal, acceptBotUrlAuth, acceptLinkUrlAuth, isWriteAccessChecked, currentAuth, - ]); +const UrlAuthModal = ({ + modal, bot, currentUser, +}: OwnProps & StateProps) => { + const { + acceptBotUrlAuth, acceptLinkUrlAuth, checkUrlAuthMatchCode, declineUrlAuth, + } = getActions(); + const lang = useLang(); - const handleDismiss = useCallback(() => { - closeUrlAuthModal(); - }, [closeUrlAuthModal]); + const renderingModal = useCurrentOrPrev(modal, false); + const currentBot = useCurrentOrPrev(bot, false); + const modalRequest = modal?.request; + const renderingRequest = renderingModal?.request; + const confirmedMatchCode = renderingModal?.matchCode; + const botName = getUserFullName(currentBot) || currentBot?.firstName; + const isMatchCodePrecheckPending = Boolean( + modalRequest?.matchCodesFirst && modalRequest.matchCodes?.length && !modal?.matchCode, + ); + const isOpen = Boolean(modal?.url && modal?.request) && !isMatchCodePrecheckPending; - const handleLoginChecked = useCallback((value: boolean) => { - setLoginChecked(value); - setWriteAccessChecked(value); - }, [setLoginChecked]); + const [isWriteAccessChecked, setWriteAccessChecked] = useState( + () => Boolean(modalRequest?.shouldRequestWriteAccess), + ); + const [selectedMatchCode, setSelectedMatchCode] = useState(); + const [dialogState, setDialogState] = useState('closed'); + const effectiveMatchCode = confirmedMatchCode || selectedMatchCode; + const isMatchDialogOpen = isMatchCodePrecheckPending || dialogState === 'match-confirm'; + const isPhoneDialogOpen = dialogState === 'phone'; + const matchCodeEmojisSelector = useCallback((global: GlobalState) => { + return renderingRequest?.matchCodes?.map((matchCode) => selectAnimatedEmoji(global, matchCode)); + }, [renderingRequest?.matchCodes]); + const matchCodeEmojis = useShallowSelector(matchCodeEmojisSelector); - // Reset on re-open useEffect(() => { - if (domain) { - setLoginChecked(true); - setWriteAccessChecked(Boolean(shouldRequestWriteAccess)); + if (!modalRequest) { + setSelectedMatchCode(undefined); + setDialogState('closed'); + return; } - }, [shouldRequestWriteAccess, domain]); + + setWriteAccessChecked(Boolean(modalRequest.shouldRequestWriteAccess)); + setSelectedMatchCode(undefined); + setDialogState('closed'); + }, [modalRequest]); + + const handleDismiss = useLastCallback(() => { + setDialogState('closed'); + declineUrlAuth(); + }); + + const submitAuth = useLastCallback((params: AcceptParams = {}) => { + const acceptAction = renderingModal?.button ? acceptBotUrlAuth : acceptLinkUrlAuth; + acceptAction({ + isWriteAllowed: renderingRequest?.shouldRequestWriteAccess ? isWriteAccessChecked : undefined, + wasPhoneShared: params.wasPhoneShared, + matchCode: params.matchCode ?? effectiveMatchCode, + }); + }); + + const handleConfirm = useLastCallback(() => { + if (!renderingRequest) { + return; + } + + if (renderingRequest.matchCodes?.length && !effectiveMatchCode) { + setDialogState('match-confirm'); + return; + } + + if (renderingRequest.shouldRequestPhoneNumber) { + setDialogState('phone'); + return; + } + + submitAuth(); + }); + + const handleMatchDialogClose = useLastCallback(() => { + setDialogState('closed'); + }); + + const handleMatchCodeSelect = useLastCallback((matchCode: string) => { + if (isMatchCodePrecheckPending) { + checkUrlAuthMatchCode({ matchCode }); + return; + } + + setSelectedMatchCode(matchCode); + setDialogState('closed'); + + if (renderingRequest?.shouldRequestPhoneNumber) { + setDialogState('phone'); + return; + } + + submitAuth({ matchCode }); + }); + + const handlePhoneDialogClose = useLastCallback(() => { + setDialogState('closed'); + }); + + const handlePhoneDecision = useLastCallback((wasPhoneShared: boolean) => { + setDialogState('closed'); + submitAuth({ wasPhoneShared }); + }); + + const handleTriggerWriteAccess = useLastCallback(() => { + setWriteAccessChecked(!isWriteAccessChecked); + }); + + if (!renderingRequest) { + return undefined; + } + + const shouldRenderSessionInfo = Boolean(renderingRequest.platform + || renderingRequest.browser || renderingRequest.ip || renderingRequest.region, + ); + const requestDomain = renderingRequest.domain; + const formattedPhoneNumber = currentUser?.phoneNumber ? `+${formatPhoneNumber(currentUser.phoneNumber)}` : undefined; + const titleText = lang('BotAuthTitle', { + url: , + }, { + withNodes: true, + }); + const descriptionText = lang('BotAuthSiteSubtitle', undefined, { + withNodes: true, + withMarkdown: true, + }); + + function renderPhoneDialogText() { + return ( + <> +

+ {lang('BotAuthPhoneNumberText', { + domain: requestDomain, + phone: formattedPhoneNumber || lang('Phone'), + }, { + withNodes: true, + withMarkdown: true, + })} +

+

{lang('BotAuthPhoneNumberQuestion')}

+ + ); + } return ( - - {renderText(lang('OpenUrlAlert2', currentAuth?.url), ['links'])} - {domain && ( - - {renderText( - lang('Conversation.OpenBotLinkLogin', [domain, getUserFullName(currentUser)]), - ['simple_markdown'], - )} - - )} - onCheck={handleLoginChecked} + <> + + - )} - {shouldRequestWriteAccess && ( - - {renderText( - lang('Conversation.OpenBotLinkAllowMessages', getUserFullName(bot)), - ['simple_markdown'], + +

{titleText}

+ + {descriptionText} + + {shouldRenderSessionInfo && ( + <> +
+ {lang('BotAuthDevice')} + + {[renderingRequest.platform, renderingRequest.browser].filter(Boolean).join(' ยท ')} + + {lang('SessionPreviewIp')} + {renderingRequest.ip} + {lang('SessionPreviewLocation')} + {renderingRequest.region} +
+ + {lang('BotAuthInfo')} + + )} + + {renderingRequest.shouldRequestWriteAccess && ( + <> + )} - - )} - onCheck={setWriteAccessChecked} - disabled={!isLoginChecked} - /> - )} -
+ > + {lang('BotAuthAllowMessages')} + + {botName && ( + + {lang( + 'BotAuthAllowMessagesInfo', + { bot: botName }, + { withNodes: true, withMarkdown: true }, + )} + + )} + + )} + +
+ + +
+ + + +
+ {renderingRequest.matchCodes?.map((matchCode, index) => { + const animatedMatchCodeEmoji = matchCodeEmojis?.[index]; + + return ( + + ); + })} +
+
+ {lang('BotAuthTitle', { + url: , + }, { + withNodes: true, + })} +
+ +
+ + + {renderPhoneDialogText()} +
+ + +
+
+ ); }; + export default memo(withGlobal( - (global): Complete => { + (global, { modal }): Complete => { const currentUser = selectUser(global, global.currentUserId!); + const bot = modal?.request?.botId ? selectUser(global, modal.request.botId) : undefined; + return { + bot, currentUser, }; }, diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index 1a82bc11d..cdc1b103d 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -33,7 +33,7 @@ import { prepareMessageReplyInfo, } from '../../helpers'; import { - addActionHandler, getGlobal, setGlobal, + addActionHandler, getActions, getGlobal, setGlobal, } from '../../index'; import { removeBlockedUser, @@ -1119,6 +1119,11 @@ addActionHandler('requestBotUrlAuth', async (global, actions, payload): Promise< if (!result) return; global = getGlobal(); + if (result.type !== 'request') { + handleUrlAuthResult(global, { url, result }, tabId); + return; + } + global = updateTabState(global, { urlAuth: { url, @@ -1130,11 +1135,16 @@ addActionHandler('requestBotUrlAuth', async (global, actions, payload): Promise< }, }, tabId); setGlobal(global); - handleUrlAuthResult(global, actions, url, result, tabId); + handleUrlAuthResult(global, { url, result }, tabId); }); addActionHandler('acceptBotUrlAuth', async (global, actions, payload): Promise => { - const { isWriteAllowed, tabId = getCurrentTabId() } = payload; + const { + isWriteAllowed, + wasPhoneShared, + matchCode: providedMatchCode, + tabId = getCurrentTabId(), + } = payload; const tabState = selectTabState(global, tabId); if (!tabState.urlAuth?.button) return; const { @@ -1152,10 +1162,12 @@ addActionHandler('acceptBotUrlAuth', async (global, actions, payload): Promise => { @@ -1164,25 +1176,80 @@ addActionHandler('requestLinkUrlAuth', async (global, actions, payload): Promise const result = await callApi('requestLinkUrlAuth', { url }); if (!result) return; global = getGlobal(); + if (result.type !== 'request') { + handleUrlAuthResult(global, { url, result }, tabId); + return; + } + global = updateTabState(global, { urlAuth: { url, }, }, tabId); setGlobal(global); - handleUrlAuthResult(global, actions, url, result, tabId); + handleUrlAuthResult(global, { url, result }, tabId); }); addActionHandler('acceptLinkUrlAuth', async (global, actions, payload): Promise => { - const { isWriteAllowed, tabId = getCurrentTabId() } = payload; + const { + isWriteAllowed, + wasPhoneShared, + matchCode: providedMatchCode, + tabId = getCurrentTabId(), + } = payload; const tabState = selectTabState(global, tabId); if (!tabState.urlAuth?.url) return; const { url } = tabState.urlAuth; - const result = await callApi('acceptLinkUrlAuth', { url, isWriteAllowed }); + const result = await callApi('acceptLinkUrlAuth', { + url, + isWriteAllowed, + wasPhoneShared, + matchCode: providedMatchCode || tabState.urlAuth.matchCode, + }); if (!result) return; global = getGlobal(); - handleUrlAuthResult(global, actions, url, result, tabId); + handleUrlAuthResult(global, { url, result, wasPhoneShared }, tabId); +}); + +addActionHandler('checkUrlAuthMatchCode', async (global, actions, payload): Promise => { + const { matchCode, tabId = getCurrentTabId() } = payload; + const url = selectTabState(global, tabId).urlAuth?.url; + if (!url) return; + + const { type } = await callApi('checkUrlAuthMatchCode', { url, matchCode }); + if (type === 'unmatched') { + actions.closeUrlAuthModal({ tabId }); + return; + } + + if (type === 'expired') { + actions.closeUrlAuthModal({ tabId }); + actions.showNotification({ message: { key: 'ErrorUrlExpired' }, tabId }); + return; + } + + global = getGlobal(); + const tabState = selectTabState(global, tabId); + if (!tabState.urlAuth) return; + + global = updateTabState(global, { + urlAuth: { + ...tabState.urlAuth, + matchCode, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('declineUrlAuth', async (global, actions, payload): Promise => { + const { tabId = getCurrentTabId() } = payload || {}; + const url = selectTabState(global, tabId).urlAuth?.url; + if (!url) return; + + actions.closeUrlAuthModal({ tabId }); + + await callApi('declineUrlAuth', { url }); }); addActionHandler('closeUrlAuthModal', (global, actions, payload): ActionReturnType => { @@ -1194,22 +1261,33 @@ addActionHandler('closeUrlAuthModal', (global, actions, payload): ActionReturnTy function handleUrlAuthResult( global: T, - actions: RequiredGlobalActions, - url: string, result: ApiUrlAuthResult, + { url, result, wasPhoneShared }: { + url: string; + result: ApiUrlAuthResult; + wasPhoneShared?: boolean; + }, ...[tabId = getCurrentTabId()]: TabArgs ) { + const actions = getActions(); + if (result.type === 'expired') { + actions.closeUrlAuthModal({ tabId }); + actions.showNotification({ message: { key: 'ErrorUrlExpired' }, tabId }); + return; + } + + const tabState = selectTabState(global, tabId); + if (result.type === 'request') { - global = getGlobal(); - const tabState = selectTabState(global, tabId); if (!tabState.urlAuth) return; - const { domain, bot, shouldRequestWriteAccess } = result; + global = getGlobal(); + const { type, bot, ...request } = result; global = updateTabState(global, { urlAuth: { ...tabState.urlAuth, + matchCode: undefined, request: { - domain, + ...request, botId: bot.id, - shouldRequestWriteAccess, }, }, }, tabId); @@ -1218,22 +1296,41 @@ function handleUrlAuthResult( } if (result.type === 'accepted' && !result.url) { - actions.showNotification({ - message: { - key: 'BotAuthSuccessText', - variables: { - url, + if (!wasPhoneShared && tabState.urlAuth?.request?.shouldRequestPhoneNumber) { + actions.showNotification({ + message: { + key: 'BotAuthSuccessTextNoPhone', + variables: { + url: tabState.urlAuth.request?.domain || result.url, + }, + options: { + withMarkdown: true, + withNodes: true, + }, }, - options: { - withMarkdown: true, - withNodes: true, + title: { + key: 'BotAuthSuccessTitle', }, - }, - title: { - key: 'BotAuthSuccessTitle', - }, - tabId, - }); + tabId, + }); + } else { + actions.showNotification({ + message: { + key: 'BotAuthSuccessText', + variables: { + url: tabState.urlAuth?.request?.domain || result.url, + }, + options: { + withMarkdown: true, + withNodes: true, + }, + }, + title: { + key: 'BotAuthSuccessTitle', + }, + tabId, + }); + } actions.closeUrlAuthModal({ tabId }); return; } diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 6900be869..862f4f82c 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -3770,7 +3770,7 @@ async function openChatWithParams( if (!isTopicProcessed) { actions.focusMessage({ chatId: chat.id, threadId, messageId, timestamp, tabId, - replyMessageId: linkContext?.messageId, + replyMessageId: linkContext?.type === 'message' ? linkContext.messageId : undefined, }); } } else if (!isCurrentChat) { diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index f813d8c15..1f91750c5 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -2308,6 +2308,8 @@ export interface ActionPayloads { acceptBotUrlAuth: { isWriteAllowed?: boolean; + wasPhoneShared?: boolean; + matchCode?: string; } & WithTabId; requestLinkUrlAuth: { @@ -2316,8 +2318,16 @@ export interface ActionPayloads { acceptLinkUrlAuth: { isWriteAllowed?: boolean; + wasPhoneShared?: boolean; + matchCode?: string; } & WithTabId; + checkUrlAuthMatchCode: { + matchCode: string; + } & WithTabId; + + declineUrlAuth: WithTabId | undefined; + // Settings loadAuthorizations: undefined; terminateAuthorization: { diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 413544026..1921ebfc3 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -57,6 +57,7 @@ import type { ApiTypePrepaidGiveaway, ApiTypeStoryView, ApiUniqueStarGiftValueInfo, + ApiUrlAuthResultRequest, ApiUser, ApiVideo, } from '../../api/types'; @@ -645,11 +646,8 @@ export type TabState = { messageId: number; buttonId: number; }; - request?: { - domain: string; - botId: string; - shouldRequestWriteAccess?: boolean; - }; + matchCode?: string; + request?: Omit & { botId: string }; url: string; }; diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index e2db78e27..b82886ac4 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1795,6 +1795,8 @@ messages.summarizeText#9d4104e2 flags:# peer:InputPeer id:int to_lang:flags.0?st messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; +messages.declineUrlAuth#35436bbc url:string = Bool; +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; updates.getState#edd4882a = updates.State; updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 169d0d9a9..4e983ceb8 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -163,6 +163,8 @@ "messages.getEmojiKeywordsDifference", "messages.requestUrlAuth", "messages.acceptUrlAuth", + "messages.declineUrlAuth", + "messages.checkUrlAuthMatchCode", "messages.hidePeerSettingsBar", "messages.getScheduledHistory", "messages.sendScheduledMessages", diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index db4773677..857d8e14f 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -112,7 +112,7 @@ --color-primary-shade: #{color.mix($color-primary, $color-black, 92%)}; --color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)}; --color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))}; - --color-primary-opacity: rgba(var(--color-primary), 0.15); + --color-primary-opacity: rgba(var(--color-primary), 0.2); --color-primary-opacity-hover: rgba(var(--color-primary), 0.25); --color-primary-tint: rgba(var(--color-primary), 0.1); --color-green: #{$color-green}; @@ -121,7 +121,7 @@ --accent-color: var(--color-primary); --accent-background-color: var(--color-primary-tint); - --accent-background-active-color: var(--color-primary-opacity); + --accent-background-active-color: var(--color-primary-opacity-hover); --color-error: #{$color-error}; --color-error-shade: #{color.mix($color-error, $color-black, 92%)}; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 04a9073fd..cbbb1a2f7 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -548,6 +548,16 @@ export interface LangPair { 'AboutPremiumDescription2': undefined; 'OpenUrlTitle': undefined; 'OpenUrlConfirm': undefined; + 'BotAuthSiteSubtitle': undefined; + 'BotAuthAllowMessages': undefined; + 'BotAuthInfo': undefined; + 'BotAuthDevice': undefined; + 'BotAuthSelectEmoji': undefined; + 'BotAuthPhoneNumber': undefined; + 'BotAuthPhoneNumberQuestion': undefined; + 'BotAuthPhoneNumberAccept': undefined; + 'BotAuthPhoneNumberDeny': undefined; + 'BotAuthLogin': undefined; 'BotWebViewOpenBot': undefined; 'BotChatMiniAppOpen': undefined; 'WebAppReloadPage': undefined; @@ -642,6 +652,7 @@ export interface LangPair { 'ErrorPasswordChanged': undefined; 'ErrorPasswordMissing': undefined; 'ErrorPasskeyUnknown': undefined; + 'ErrorUrlExpired': undefined; 'ErrorUnspecified': undefined; 'NoStickers': undefined; 'ClearRecentEmoji': undefined; @@ -2181,13 +2192,16 @@ export interface LangPairWithVariables { 'OpenUrlText': { 'url': V; }; - 'ConversationOpenBotLinkLogin': { + 'BotAuthTitle': { 'url': V; - 'user': V; }; - 'ConversationOpenBotLinkAllowMessages': { + 'BotAuthAllowMessagesInfo': { 'bot': V; }; + 'BotAuthPhoneNumberText': { + 'domain': V; + 'phone': V; + }; 'ForwardForStars': { 'price': V; }; @@ -3575,6 +3589,9 @@ export interface LangPairWithVariables { 'BotAuthSuccessText': { 'url': V; }; + 'BotAuthSuccessTextNoPhone': { + 'url': V; + }; 'RankModalMemberText': { 'tag': V; 'author': V; diff --git a/src/util/browser/windowEnvironment.ts b/src/util/browser/windowEnvironment.ts index ab97c54f7..8ebe82d26 100644 --- a/src/util/browser/windowEnvironment.ts +++ b/src/util/browser/windowEnvironment.ts @@ -66,12 +66,8 @@ export const IS_EMOJI_SUPPORTED = PLATFORM_ENV && (IS_MAC_OS || IS_IOS) && isLas export const IS_SERVICE_WORKER_SUPPORTED = 'serviceWorker' in navigator; -// Remove in mid-late 2025 when Chromium 132 is no longer a problem -// https://issues.chromium.org/issues/390581541 -const chromeVersion = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)?.[2]; -const hasBrokenServiceWorkerStreaming = chromeVersion && Number(chromeVersion) === 132; // TODO Consider failed service worker -export const IS_PROGRESSIVE_SUPPORTED = IS_SERVICE_WORKER_SUPPORTED && !hasBrokenServiceWorkerStreaming; +export const IS_PROGRESSIVE_SUPPORTED = IS_SERVICE_WORKER_SUPPORTED; export const IS_OPUS_SUPPORTED = Boolean((new Audio()).canPlayType('audio/ogg; codecs=opus')); export const IS_CANVAS_FILTER_SUPPORTED = ( !IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {}) diff --git a/src/util/deepLinkParser.ts b/src/util/deepLinkParser.ts index 8e1b4214d..624105495 100644 --- a/src/util/deepLinkParser.ts +++ b/src/util/deepLinkParser.ts @@ -10,7 +10,7 @@ import { isUsernameValid } from './entities/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' | 'message' | 'premium_offer' | 'premium_multigift' | 'stars_topup' - | 'nft' | 'stars' | 'ton' | 'stargift_auction' | 'premium'; + | 'nft' | 'stars' | 'ton' | 'stargift_auction' | 'premium' | 'oauth'; interface PublicMessageLink { type: 'publicMessageLink'; @@ -122,6 +122,11 @@ interface SettingsScreenLink { screen?: 'devices' | 'folders' | 'language' | 'privacy' | 'editProfile' | 'theme'; } +interface OAuthLink { + type: 'oauth'; + url: string; +} + type DeepLink = TelegramPassportLink | LoginCodeLink | @@ -139,7 +144,8 @@ type DeepLink = GiftAuctionLink | StarsModalLink | TonModalLink | - SettingsScreenLink; + SettingsScreenLink | + OAuthLink; type BuilderParams = Record, string | undefined>; type BuilderReturnType = T | undefined; @@ -157,6 +163,10 @@ type PublicUsernameOrBotLinkBuilderParams = Omit, 'url'> & { + url: string; +}; + const ELIGIBLE_HOSTNAMES = new Set(['t.me', 'telegram.me', 'telegram.dog']); export function isDeepLink(link: string): boolean { @@ -283,6 +293,8 @@ function parseTgLink(url: URL) { return { type: 'ton' } satisfies TonModalLink; case 'settings': return buildSettingsScreenLink({ screen: pathParams.length === 1 ? pathParams[0] : undefined }); + case 'oauth': + return buildOAuthLink({ url: url.toString() }); default: break; } @@ -455,12 +467,14 @@ function getTgDeepLinkType( switch (method) { case 'resolve': { const { - - domain, post, bot_id, scope, public_key, nonce, + domain, post, bot_id, scope, public_key, nonce, startapp, } = queryParams; if (domain === 'telegrampassport' && bot_id && scope && public_key && nonce) { return 'telegramPassportLink'; } + if (domain === 'oauth' && startapp) { + return 'oauth'; + } if (domain && post) { return 'publicMessageLink'; } @@ -505,6 +519,8 @@ function getTgDeepLinkType( case 'settings': { return 'settings'; } + case 'oauth': + return 'oauth'; default: break; } @@ -773,6 +789,15 @@ function buildSettingsScreenLink(params: BuilderParams): Bui }; } +function buildOAuthLink(params: OAuthLinkBuilderParams): BuilderReturnType { + const { + url, + } = params; + return { + type: 'oauth', + url, + }; +} function buildPremiumReferrerLink(params: BuilderParams): BuilderReturnType { const { ref, diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index b0db574f5..35ff47933 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -90,30 +90,34 @@ export const processDeepLink = (url: string, linkContext?: LinkContext): boolean switch (parsedLink.screen) { case 'editProfile': actions.openSettingsScreen({ screen: SettingsScreens.EditProfile }); - break; + return true; case 'language': actions.openSettingsScreen({ screen: SettingsScreens.Language }); - break; + return true; case 'devices': actions.openSettingsScreen({ screen: SettingsScreens.ActiveSessions }); - break; + return true; case 'privacy': actions.openSettingsScreen({ screen: SettingsScreens.Privacy }); - break; + return true; case 'folders': actions.openSettingsScreen({ screen: SettingsScreens.Folders }); - break; + return true; case 'theme': actions.openSettingsScreen({ screen: SettingsScreens.General }); - break; + return true; } - return true; + break; case 'stars': actions.openStarsBalanceModal({}); - break; + return true; case 'ton': actions.openStarsBalanceModal({ currency: TON_CURRENCY_CODE }); - break; + return true; + case 'oauth': + if (linkContext?.type !== 'inner') return false; + actions.requestLinkUrlAuth({ url: parsedLink.url }); + return true; default: break; }