diff --git a/src/api/gramjs/apiBuilders/bots.ts b/src/api/gramjs/apiBuilders/bots.ts index 67b183db1..df37730d0 100644 --- a/src/api/gramjs/apiBuilders/bots.ts +++ b/src/api/gramjs/apiBuilders/bots.ts @@ -13,32 +13,280 @@ import type { ApiBotInlineSwitchPm, ApiBotInlineSwitchWebview, ApiBotMenuButton, + ApiInlineQueryPeerType, ApiInlineResultType, + ApiKeyboardButton, ApiMessagesBotApp, + ApiReplyKeyboard, + MediaContainer, + MediaContent, } from '../../types'; import { numberToHexColor } from '../../../util/colors'; import { pick } from '../../../util/iteratees'; +import { generateRandomInt } from '../gramjsBuilders'; import { addDocumentToLocalDb } from '../helpers/localDb'; -import { buildApiPhoto, buildApiThumbnailFromStripped } from './common'; +import { serializeBytes } from '../helpers/misc'; +import { buildApiMessageEntity, buildApiPhoto } from './common'; import { omitVirtualClassFields } from './helpers'; -import { buildApiDocument, buildApiWebDocument, buildVideoFromDocument } from './messageContent'; +import { + buildApiDocument, + buildApiWebDocument, + buildAudioFromDocument, + buildGeoPoint, + buildVideoFromDocument, +} from './messageContent'; import { buildSvgPath } from './pathBytesToSvg'; import { buildApiPeerId } from './peers'; import { buildStickerFromDocument } from './symbols'; +export function buildReplyButtons( + replyMarkup: GramJs.TypeReplyMarkup | undefined, + receiptMessageId?: number, +): ApiReplyKeyboard | undefined { + if (!(replyMarkup instanceof GramJs.ReplyKeyboardMarkup || replyMarkup instanceof GramJs.ReplyInlineMarkup)) { + return undefined; + } + + const markup = replyMarkup.rows.map(({ buttons }) => { + return buttons.map((button): ApiKeyboardButton | undefined => { + const { text } = button; + + if (button instanceof GramJs.KeyboardButton) { + return { + type: 'command', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonUrl) { + if (button.url.includes('?startgroup=')) { + return { + type: 'unsupported', + text, + }; + } + + return { + type: 'url', + text, + url: button.url, + }; + } + + if (button instanceof GramJs.KeyboardButtonCallback) { + if (button.requiresPassword) { + return { + type: 'unsupported', + text, + }; + } + + return { + type: 'callback', + text, + data: serializeBytes(button.data), + }; + } + + if (button instanceof GramJs.KeyboardButtonRequestPoll) { + return { + type: 'requestPoll', + text, + isQuiz: button.quiz, + }; + } + + if (button instanceof GramJs.KeyboardButtonRequestPhone) { + return { + type: 'requestPhone', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonBuy) { + if (receiptMessageId) { + return { + type: 'receipt', + receiptMessageId, + }; + } + return { + type: 'buy', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonGame) { + return { + type: 'game', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonSwitchInline) { + return { + type: 'switchBotInline', + text, + query: button.query, + isSamePeer: button.samePeer, + }; + } + + if (button instanceof GramJs.KeyboardButtonUserProfile) { + return { + type: 'userProfile', + text, + userId: button.userId.toString(), + }; + } + + if (button instanceof GramJs.KeyboardButtonSimpleWebView) { + return { + type: 'simpleWebView', + text, + url: button.url, + }; + } + + if (button instanceof GramJs.KeyboardButtonWebView) { + return { + type: 'webView', + text, + url: button.url, + }; + } + + if (button instanceof GramJs.KeyboardButtonUrlAuth) { + return { + type: 'urlAuth', + text, + url: button.url, + buttonId: button.buttonId, + }; + } + + if (button instanceof GramJs.KeyboardButtonCopy) { + return { + type: 'copy', + text, + copyText: button.copyText, + }; + } + + return { + type: 'unsupported', + text, + }; + }).filter(Boolean); + }); + + if (markup.every((row) => !row.length)) return undefined; + + return { + [replyMarkup instanceof GramJs.ReplyKeyboardMarkup ? 'keyboardButtons' : 'inlineButtons']: markup, + ...(replyMarkup instanceof GramJs.ReplyKeyboardMarkup && { + keyboardPlaceholder: replyMarkup.placeholder, + isKeyboardSingleUse: replyMarkup.singleUse, + isKeyboardSelective: replyMarkup.selective, + }), + }; +} + +export function buildBotInlineMessage( + sendMessage: GramJs.TypeBotInlineMessage, type: string, document?: GramJs.TypeDocument, photo?: GramJs.TypePhoto, +): MediaContainer & { replyMarkup?: ApiReplyKeyboard } { + const content: MediaContent = {}; + + if (sendMessage instanceof GramJs.BotInlineMessageText) { + content.text = { + text: sendMessage.message, + entities: sendMessage.entities?.map(buildApiMessageEntity), + }; + } else if (sendMessage instanceof GramJs.BotInlineMessageMediaAuto) { + if (type === 'photo' && photo instanceof GramJs.Photo) { + content.photo = buildApiPhoto(photo); + } else if (type === 'audio' && document instanceof GramJs.Document) { + content.audio = buildAudioFromDocument(document); + } else if (type === 'video' && document instanceof GramJs.Document) { + content.video = buildVideoFromDocument(document); + } else if (type === 'sticker' && document instanceof GramJs.Document) { + content.sticker = buildStickerFromDocument(document); + } else if (type === 'file' && document instanceof GramJs.Document) { + content.document = buildApiDocument(document); + } else if (type === 'gif' && document instanceof GramJs.Document) { + content.video = buildVideoFromDocument(document); + } else { + content.text = { + text: sendMessage.message, + entities: sendMessage.entities?.map(buildApiMessageEntity), + }; + } + } else if (sendMessage instanceof GramJs.BotInlineMessageMediaGeo) { + content.location = { + mediaType: 'geo', + geo: buildGeoPoint(sendMessage.geo)!, + }; + } else if (sendMessage instanceof GramJs.BotInlineMessageMediaVenue) { + content.location = { + mediaType: 'venue', + geo: buildGeoPoint(sendMessage.geo)!, + title: sendMessage.title, + address: sendMessage.address, + provider: sendMessage.provider, + venueId: sendMessage.venueId, + venueType: sendMessage.venueType, + }; + } else if (sendMessage instanceof GramJs.BotInlineMessageMediaContact) { + content.contact = { + mediaType: 'contact', + phoneNumber: sendMessage.phoneNumber, + firstName: sendMessage.firstName, + lastName: sendMessage.lastName, + userId: '0', + }; + } else if (sendMessage instanceof GramJs.BotInlineMessageMediaInvoice) { + content.invoice = { + mediaType: 'invoice', + isTest: sendMessage.test, + title: sendMessage.title, + description: sendMessage.description, + photo: buildApiWebDocument(sendMessage.photo), + currency: sendMessage.currency, + amount: sendMessage.totalAmount.toJSNumber(), + }; + } else { + const mediaSize = sendMessage.forceSmallMedia ? 'small' : sendMessage.forceLargeMedia ? 'large' : undefined; + + content.webPage = { + mediaType: 'webpage', + id: generateRandomInt(), + mediaSize, + url: sendMessage.url, + displayUrl: sendMessage.url, + }; + } + + return { + content, + replyMarkup: buildReplyButtons(sendMessage.replyMarkup) || undefined, + }; +} + export function buildApiBotInlineResult(result: GramJs.BotInlineResult, queryId: string): ApiBotInlineResult { const { - id, type, title, description, url, thumb, + id, type, title, description, url, thumb, content, sendMessage, } = result; return { id, queryId, type: type as ApiInlineResultType, + sendMessage: buildBotInlineMessage(sendMessage, type), title, description, url, + content: buildApiWebDocument(content), webThumbnail: buildApiWebDocument(thumb), }; } @@ -47,7 +295,7 @@ export function buildApiBotInlineMediaResult( result: GramJs.BotInlineMediaResult, queryId: string, ): ApiBotInlineMediaResult { const { - id, type, title, description, photo, document, + id, type, title, description, sendMessage, photo, document, } = result; return { @@ -59,9 +307,10 @@ export function buildApiBotInlineMediaResult( ...(type === 'sticker' && document instanceof GramJs.Document && { sticker: buildStickerFromDocument(document) }), ...(photo instanceof GramJs.Photo && { photo: buildApiPhoto(photo) }), ...(type === 'gif' && document instanceof GramJs.Document && { gif: buildVideoFromDocument(document) }), - ...(type === 'video' && document instanceof GramJs.Document && { - thumbnail: buildApiThumbnailFromStripped(document.thumbs), - }), + ...(type === 'file' && document instanceof GramJs.Document && { document: buildApiDocument(document) }), + ...(type === 'audio' && document instanceof GramJs.Document && { audio: buildAudioFromDocument(document) }), + ...(type === 'video' && document instanceof GramJs.Document && { video: buildVideoFromDocument(document) }), + sendMessage: buildBotInlineMessage(sendMessage, type, document, photo), }; } @@ -80,20 +329,22 @@ export function buildApiAttachBot(bot: GramJs.AttachMenuBot): ApiAttachBot { shortName: bot.shortName, isForAttachMenu: bot.showInAttachMenu!, isForSideMenu: bot.showInSideMenu, - attachMenuPeerTypes: bot.peerTypes?.map(buildApiAttachMenuPeerType)!, + attachMenuPeerTypes: bot.peerTypes && buildApiAttachMenuPeerType(bot.peerTypes), icons: bot.icons.map(buildApiAttachMenuIcon).filter(Boolean), isInactive: bot.inactive, isDisclaimerNeeded: bot.sideMenuDisclaimerNeeded, }; } -function buildApiAttachMenuPeerType(peerType: GramJs.TypeAttachMenuPeerType): ApiAttachMenuPeerType { - if (peerType instanceof GramJs.AttachMenuPeerTypeBotPM) return 'bots'; - if (peerType instanceof GramJs.AttachMenuPeerTypePM) return 'users'; - if (peerType instanceof GramJs.AttachMenuPeerTypeChat) return 'chats'; - if (peerType instanceof GramJs.AttachMenuPeerTypeBroadcast) return 'channels'; - if (peerType instanceof GramJs.AttachMenuPeerTypeSameBotPM) return 'self'; - return undefined!; // Never reached +function buildApiAttachMenuPeerType(peerTypes: GramJs.TypeAttachMenuPeerType[]): ApiAttachMenuPeerType[] { + return peerTypes.flatMap((peerType) => { + if (peerType instanceof GramJs.AttachMenuPeerTypeBotPM) return ['bots']; + if (peerType instanceof GramJs.AttachMenuPeerTypePM) return ['users']; + if (peerType instanceof GramJs.AttachMenuPeerTypeChat) return ['chats', 'groups']; + if (peerType instanceof GramJs.AttachMenuPeerTypeBroadcast) return ['channels']; + if (peerType instanceof GramJs.AttachMenuPeerTypeSameBotPM) return ['self']; + return []; + }); } function buildApiAttachMenuIcon(icon: GramJs.AttachMenuBotIcon): ApiAttachBotIcon | undefined { @@ -200,3 +451,13 @@ export function buildApiMessagesBotApp(botApp: GramJs.messages.BotApp): ApiMessa shouldRequestWriteAccess: requestWriteAccess, }; } + +export function buildApiInlineQueryPeerType(peerType: GramJs.TypeInlineQueryPeerType): ApiInlineQueryPeerType { + if (peerType instanceof GramJs.InlineQueryPeerTypeBotPM) return 'bots'; + if (peerType instanceof GramJs.InlineQueryPeerTypePM) return 'users'; + if (peerType instanceof GramJs.InlineQueryPeerTypeChat) return 'chats'; + if (peerType instanceof GramJs.InlineQueryPeerTypeMegagroup) return 'supergroups'; + if (peerType instanceof GramJs.InlineQueryPeerTypeBroadcast) return 'channels'; + if (peerType instanceof GramJs.InlineQueryPeerTypeSameBotPM) return 'self'; + return undefined!; // Never reached +} diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 37383b2da..09ead162b 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -8,7 +8,6 @@ import type { ApiFactCheck, ApiInputMessageReplyInfo, ApiInputReplyInfo, - ApiKeyboardButton, ApiMessage, ApiMessageEntity, ApiMessageForwardInfo, @@ -17,9 +16,9 @@ import type { ApiPeer, ApiPhoto, ApiPoll, + ApiPreparedInlineMessage, ApiQuickReply, ApiReplyInfo, - ApiReplyKeyboard, ApiSponsoredMessage, ApiSticker, ApiStory, @@ -28,9 +27,7 @@ import type { ApiVideo, MediaContent, } from '../../types'; -import { - ApiMessageEntityTypes, MAIN_THREAD_ID, -} from '../../types'; +import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../types'; import { DELETED_COMMENTS_CHANNEL_ID, @@ -47,10 +44,18 @@ import { getServerTime, getServerTimeOffset } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; import { buildPeer } from '../gramjsBuilders'; import { + addDocumentToLocalDb, addPhotoToLocalDb, + addWebDocumentToLocalDb, type MediaRepairContext, } from '../helpers/localDb'; import { resolveMessageApiChatId, serializeBytes } from '../helpers/misc'; +import { + buildApiBotInlineMediaResult, + buildApiBotInlineResult, + buildApiInlineQueryPeerType, + buildReplyButtons, +} from './bots'; import { buildApiFormattedText, buildApiPhoto, @@ -193,7 +198,7 @@ export function buildApiMessageWithChatId( const isEdited = Boolean(mtpMessage.editDate) && !mtpMessage.editHide; const { inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective, - } = buildReplyButtons(mtpMessage, isInvoiceMedia) || {}; + } = buildReplyButtons(mtpMessage.replyMarkup, mtpMessage.id) || {}; const { mediaUnread: isMediaUnread, postAuthor } = mtpMessage; const groupedId = mtpMessage.groupedId && String(mtpMessage.groupedId); const isInAlbum = Boolean(groupedId) && !(content.document || content.audio || content.sticker); @@ -357,159 +362,6 @@ export function buildApiFactCheck(factCheck: GramJs.FactCheck): ApiFactCheck { }; } -function buildReplyButtons(message: UniversalMessage, shouldSkipBuyButton?: boolean): ApiReplyKeyboard | undefined { - const { replyMarkup, media } = message; - - if (!(replyMarkup instanceof GramJs.ReplyKeyboardMarkup || replyMarkup instanceof GramJs.ReplyInlineMarkup)) { - return undefined; - } - - const markup = replyMarkup.rows.map(({ buttons }) => { - return buttons.map((button): ApiKeyboardButton | undefined => { - const { text } = button; - - if (button instanceof GramJs.KeyboardButton) { - return { - type: 'command', - text, - }; - } - - if (button instanceof GramJs.KeyboardButtonUrl) { - if (button.url.includes('?startgroup=')) { - return { - type: 'unsupported', - text, - }; - } - - return { - type: 'url', - text, - url: button.url, - }; - } - - if (button instanceof GramJs.KeyboardButtonCallback) { - if (button.requiresPassword) { - return { - type: 'unsupported', - text, - }; - } - - return { - type: 'callback', - text, - data: serializeBytes(button.data), - }; - } - - if (button instanceof GramJs.KeyboardButtonRequestPoll) { - return { - type: 'requestPoll', - text, - isQuiz: button.quiz, - }; - } - - if (button instanceof GramJs.KeyboardButtonRequestPhone) { - return { - type: 'requestPhone', - text, - }; - } - - if (button instanceof GramJs.KeyboardButtonBuy) { - if (media instanceof GramJs.MessageMediaInvoice && media.receiptMsgId) { - return { - type: 'receipt', - receiptMessageId: media.receiptMsgId, - }; - } - if (shouldSkipBuyButton) return undefined; - return { - type: 'buy', - text, - }; - } - - if (button instanceof GramJs.KeyboardButtonGame) { - return { - type: 'game', - text, - }; - } - - if (button instanceof GramJs.KeyboardButtonSwitchInline) { - return { - type: 'switchBotInline', - text, - query: button.query, - isSamePeer: button.samePeer, - }; - } - - if (button instanceof GramJs.KeyboardButtonUserProfile) { - return { - type: 'userProfile', - text, - userId: button.userId.toString(), - }; - } - - if (button instanceof GramJs.KeyboardButtonSimpleWebView) { - return { - type: 'simpleWebView', - text, - url: button.url, - }; - } - - if (button instanceof GramJs.KeyboardButtonWebView) { - return { - type: 'webView', - text, - url: button.url, - }; - } - - if (button instanceof GramJs.KeyboardButtonUrlAuth) { - return { - type: 'urlAuth', - text, - url: button.url, - buttonId: button.buttonId, - }; - } - - if (button instanceof GramJs.KeyboardButtonCopy) { - return { - type: 'copy', - text, - copyText: button.copyText, - }; - } - - return { - type: 'unsupported', - text, - }; - }).filter(Boolean); - }); - - if (markup.every((row) => !row.length)) return undefined; - - return { - [replyMarkup instanceof GramJs.ReplyKeyboardMarkup ? 'keyboardButtons' : 'inlineButtons']: markup, - ...(replyMarkup instanceof GramJs.ReplyKeyboardMarkup && { - keyboardPlaceholder: replyMarkup.placeholder, - isKeyboardSingleUse: replyMarkup.singleUse, - isKeyboardSelective: replyMarkup.selective, - }), - }; -} - function buildNewPoll(poll: ApiNewPoll, localId: number): ApiPoll { return { mediaType: 'poll', @@ -879,3 +731,36 @@ export function buildApiReportResult( options, }; } + +function processInlineBotResult(queryId: string, result: GramJs.TypeBotInlineResult) { + if (result instanceof GramJs.BotInlineMediaResult) { + if (result.document instanceof GramJs.Document) { + addDocumentToLocalDb(result.document); + } + + if (result.photo instanceof GramJs.Photo) { + addPhotoToLocalDb(result.photo); + } + + return buildApiBotInlineMediaResult(result, queryId); + } + + if (result.thumb) { + addWebDocumentToLocalDb(result.thumb); + } + + return buildApiBotInlineResult(result, queryId); +} + +export function buildPreparedInlineMessage( + result: GramJs.messages.TypePreparedInlineMessage, +): ApiPreparedInlineMessage { + const queryId = result.queryId.toString(); + + return { + queryId, + result: processInlineBotResult(queryId, result.result), + peerTypes: result.peerTypes?.map(buildApiInlineQueryPeerType), + cacheTime: result.cacheTime, + }; +} diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index af7b97d91..d25f272f3 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -24,11 +24,15 @@ import type { ApiSticker, ApiStory, ApiStorySkipped, + ApiUser, ApiUserStatus, ApiVideo, MediaContent, } from '../../types'; -import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../types'; +import { + MAIN_THREAD_ID, + MESSAGE_DELETED, +} from '../../types'; import { API_GENERAL_ID_LIMIT, @@ -65,6 +69,7 @@ import { buildApiThreadInfo, buildLocalForwardedMessage, buildLocalMessage, + buildPreparedInlineMessage, buildUploadingMedia, } from '../apiBuilders/messages'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; @@ -2178,3 +2183,18 @@ export async function exportMessageLink({ return result?.link; } + +export async function fetchPreparedInlineMessage({ + bot, id, +}: { + bot: ApiUser; + id: string; +}) { + const result = await invokeRequest(new GramJs.messages.GetPreparedInlineMessage({ + bot: buildInputEntity(bot.id, bot.accessHash) as GramJs.InputUser, + id, + })); + if (!result) return undefined; + + return buildPreparedInlineMessage(result); +} diff --git a/src/api/types/bots.ts b/src/api/types/bots.ts index 34a0cc82c..c3436444a 100644 --- a/src/api/types/bots.ts +++ b/src/api/types/bots.ts @@ -1,11 +1,14 @@ import type { - ApiDimensions, - ApiPhoto, ApiSticker, ApiThumbnail, ApiVideo, MediaContainer, + ApiDimensions, ApiDocument, + ApiPhoto, ApiReplyKeyboard, + ApiSticker, ApiThumbnail, + ApiVideo, MediaContainer, + MediaContent, } from './messages'; export type ApiInlineResultType = ( 'article' | 'audio' | 'contact' | 'document' | 'game' | 'gif' | 'location' | 'mpeg4_gif' | - 'photo' | 'sticker' | 'venue' | 'video' | 'voice' | 'file' + 'photo' | 'sticker' | 'venue' | 'video' | 'voice' | 'file' | 'geo' ); export interface ApiWebDocument { @@ -17,6 +20,11 @@ export interface ApiWebDocument { dimensions?: ApiDimensions; } +export type ApiBotInlineMessage = { + content: MediaContent; + replyMarkup?: ApiReplyKeyboard; +}; + export interface ApiBotInlineResult { id: string; queryId: string; @@ -24,7 +32,9 @@ export interface ApiBotInlineResult { title?: string; description?: string; url?: string; + content?: ApiWebDocument; webThumbnail?: ApiWebDocument; + sendMessage: ApiBotInlineMessage; } export interface ApiBotInlineMediaResult { @@ -34,9 +44,11 @@ export interface ApiBotInlineMediaResult { title?: string; description?: string; sticker?: ApiSticker; + document?: ApiDocument; photo?: ApiPhoto; gif?: ApiVideo; thumbnail?: ApiThumbnail; + sendMessage: ApiBotInlineMessage; } export interface ApiBotInlineSwitchPm { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 2db8f464a..d1fcd0d00 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1,5 +1,9 @@ import type { ThreadId, WebPageMediaSize } from '../../types'; -import type { ApiWebDocument } from './bots'; +import type { + ApiBotInlineMediaResult, + ApiBotInlineResult, + ApiWebDocument, +} from './bots'; import type { ApiPeerColor } from './chats'; import type { ApiMessageAction } from './messageActions'; import type { @@ -9,6 +13,7 @@ import type { import type { ApiMessageStoryData, ApiStory, ApiWebPageStickerData, ApiWebPageStoryData, } from './stories'; +import type { ApiInlineQueryPeerType } from './users'; export interface ApiDimensions { width: number; @@ -255,12 +260,12 @@ export interface ApiGeoPoint { accuracyRadius?: number; } -interface ApiGeo { +export interface ApiGeo { mediaType: 'geo'; geo: ApiGeoPoint; } -interface ApiVenue { +export interface ApiVenue { mediaType: 'venue'; geo: ApiGeoPoint; title: string; @@ -270,11 +275,11 @@ interface ApiVenue { venueType: string; } -interface ApiGeoLive { +export interface ApiGeoLive { mediaType: 'geoLive'; geo: ApiGeoPoint; heading?: number; - period: number; + period?: number; } export type ApiLocation = ApiGeo | ApiVenue | ApiGeoLive; @@ -915,6 +920,13 @@ export type ApiSponsoredMessageReportResult = { }[]; }; +export type ApiPreparedInlineMessage = { + queryId: string; + result: ApiBotInlineResult | ApiBotInlineMediaResult; + peerTypes: ApiInlineQueryPeerType[]; + cacheTime: number; +}; + export const MAIN_THREAD_ID = -1; // `Symbol` can not be transferred from worker diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 8831dd2d1..aac75e2d8 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -103,9 +103,11 @@ export interface ApiUsername { export type ApiChatType = typeof API_CHAT_TYPES[number]; export type ApiAttachMenuPeerType = 'self' | ApiChatType; +export type ApiInlineQueryPeerType = 'self' | 'supergroups' | ApiChatType; + type ApiAttachBotForMenu = { isForAttachMenu: true; - attachMenuPeerTypes: ApiAttachMenuPeerType[]; + attachMenuPeerTypes?: ApiAttachMenuPeerType[]; }; type ApiAttachBotBase = { diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 7d900deec..d75af56b4 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -154,6 +154,8 @@ "Code" = "Code"; "Open" = "Open"; "LoginHeaderPassword" = "Enter Password"; +"BotShareMessageShare" = "Share with..."; +"BotShareMessage" = "Share Message"; "LoginEnterPasswordDescription" = "You have Two-Step Verification enabled, so your account is protected with an additional password."; "StartText" = "Please confirm your country code\nand enter your phone number."; "LoginPhonePlaceholder" = "Your phone number"; @@ -538,8 +540,10 @@ "EditAdminTransferSetPassword" = "Set Password"; "WebAppAddToAttachmentText" = "{bot} requests your permission to be added as an option to your attachment menu, so you can access it from any chat."; "BotOpenPageTitle" = "Open page"; +"WebAppShareMessageInfo" = "{user} mini app suggests you to send this message to a chat you select."; "BotPermissionGameAlert" = "Allow {bot} to pass your Telegram name and id (not your phone number) to pages you open with this bot?"; "BotOpenPageMessage" = "**{bot}** would like to open its web app to proceed.\n\nIt will be able to access your **IP address** and basic device info."; +"BotSharedToOne" = "Sent message to {peer}"; "FilterDeleteAlert" = "Are you sure you want to remove this folder? Your chats will not be deleted."; "RequestToJoinChannelSentDescription" = "You will be added to the channel once its admins approve your request."; "RequestToJoinGroupSentDescription" = "You will be added to the group once an admin approves your request."; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 283be8671..9112a8352 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -33,6 +33,9 @@ export { default as EmojiStatusAccessModal } from '../components/modals/emojiSta export { default as LocationAccessModal } from '../components/modals/locationAccess/LocationAccessModal'; export { default as ReportAdModal } from '../components/modals/reportAd/ReportAdModal'; export { default as ReportModal } from '../components/modals/reportModal/ReportModal'; +export { default as PreparedMessageModal } from '../components/modals/preparedMessage/PreparedMessageModal'; +export { default as SharePreparedMessageModal } + from '../components/modals/sharePreparedMessage/SharePreparedMessageModal'; export { default as CalendarModal } from '../components/common/CalendarModal'; export { default as DeleteMessageModal } from '../components/common/DeleteMessageModal'; export { default as PinMessageModal } from '../components/common/PinMessageModal'; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index aa7e3fea3..8bc9101e7 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -1125,10 +1125,11 @@ const Composer: FC = ({ const { id, queryId, isSilent } = args; sendInlineBotResult({ id, + chatId, + threadId, queryId, scheduledAt, isSilent, - messageList, }); return; } @@ -1272,8 +1273,9 @@ const Composer: FC = ({ sendInlineBotResult({ id: inlineResult.id, queryId: inlineResult.queryId, + threadId, + chatId, isSilent, - messageList: currentMessageList!, }); } diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index 3b235c294..87df0031c 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -77,7 +77,7 @@ const RecipientPicker: FC = ({ const chatFullInfo = selectChatFullInfo(global, id); - return chat && chatFullInfo && getCanPostInChat(chat, undefined, undefined, chatFullInfo); + return chat && (!chatFullInfo || getCanPostInChat(chat, undefined, undefined, chatFullInfo)); }); const sorted = sortChatIds( diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index 86f9c006d..a795d5024 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -154,7 +154,8 @@ const AttachMenu: FC = ({ return attachBots ? Object.values(attachBots).filter((bot) => { if (!peerType || !bot.isForAttachMenu) return false; - if (peerType === 'bots' && bot.id === chatId && bot.attachMenuPeerTypes.includes('self')) { + if (peerType === 'bots' && bot.id === chatId + && bot.attachMenuPeerTypes && bot.attachMenuPeerTypes.includes('self')) { return true; } return bot.attachMenuPeerTypes!.includes(peerType); diff --git a/src/components/middle/composer/inlineResults/ArticleResult.tsx b/src/components/middle/composer/inlineResults/ArticleResult.tsx index b72ce3cea..3580628eb 100644 --- a/src/components/middle/composer/inlineResults/ArticleResult.tsx +++ b/src/components/middle/composer/inlineResults/ArticleResult.tsx @@ -1,7 +1,7 @@ import type { FC } from '../../../../lib/teact/teact'; import React, { memo } from '../../../../lib/teact/teact'; -import type { ApiBotInlineResult } from '../../../../api/types'; +import type { ApiBotInlineMediaResult, ApiBotInlineResult } from '../../../../api/types'; import useLastCallback from '../../../../hooks/useLastCallback'; @@ -9,15 +9,18 @@ import BaseResult from './BaseResult'; export type OwnProps = { focus?: boolean; - inlineResult: ApiBotInlineResult; - onClick: (result: ApiBotInlineResult) => void; + inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult; + onClick: (result: ApiBotInlineResult | ApiBotInlineMediaResult) => void; }; const ArticleResult: FC = ({ focus, inlineResult, onClick }) => { const { - title, url, description, webThumbnail, + title, description, } = inlineResult; + const url = 'url' in inlineResult ? inlineResult.url : undefined; + const webThumbnail = 'webThumbnail' in inlineResult ? inlineResult.webThumbnail : undefined; + const handleClick = useLastCallback(() => { onClick(inlineResult); }); diff --git a/src/components/middle/composer/inlineResults/GifResult.tsx b/src/components/middle/composer/inlineResults/GifResult.tsx index ce630941f..ebc2dd796 100644 --- a/src/components/middle/composer/inlineResults/GifResult.tsx +++ b/src/components/middle/composer/inlineResults/GifResult.tsx @@ -1,7 +1,7 @@ import type { FC } from '../../../../lib/teact/teact'; import React, { memo } from '../../../../lib/teact/teact'; -import type { ApiBotInlineMediaResult, ApiBotInlineResult, ApiVideo } from '../../../../api/types'; +import type { ApiBotInlineMediaResult, ApiVideo } from '../../../../api/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../../hooks/useLastCallback'; @@ -13,7 +13,7 @@ type OwnProps = { isSavedMessages?: boolean; canSendGifs?: boolean; observeIntersection: ObserveFn; - onClick: (result: ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean) => void; + onClick: (result: ApiBotInlineMediaResult, isSilent?: boolean, shouldSchedule?: boolean) => void; }; const GifResult: FC = ({ diff --git a/src/components/middle/composer/inlineResults/MediaResult.tsx b/src/components/middle/composer/inlineResults/MediaResult.tsx index 33a322aea..cce8793e7 100644 --- a/src/components/middle/composer/inlineResults/MediaResult.tsx +++ b/src/components/middle/composer/inlineResults/MediaResult.tsx @@ -20,7 +20,7 @@ export type OwnProps = { focus?: boolean; isForGallery?: boolean; inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult; - onClick: (result: ApiBotInlineResult) => void; + onClick: (result: ApiBotInlineMediaResult | ApiBotInlineResult) => void; }; const MediaResult: FC = ({ diff --git a/src/components/middle/composer/inlineResults/StickerResult.tsx b/src/components/middle/composer/inlineResults/StickerResult.tsx index bc6b3480c..dfcd9f34a 100644 --- a/src/components/middle/composer/inlineResults/StickerResult.tsx +++ b/src/components/middle/composer/inlineResults/StickerResult.tsx @@ -1,7 +1,7 @@ import type { FC } from '../../../../lib/teact/teact'; import React, { memo } from '../../../../lib/teact/teact'; -import type { ApiBotInlineMediaResult, ApiBotInlineResult } from '../../../../api/types'; +import type { ApiBotInlineMediaResult } from '../../../../api/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; import { STICKER_SIZE_INLINE_BOT_RESULT } from '../../../../config'; @@ -12,7 +12,7 @@ type OwnProps = { inlineResult: ApiBotInlineMediaResult; isSavedMessages?: boolean; observeIntersection: ObserveFn; - onClick: (result: ApiBotInlineResult, isSilent?: boolean, shouldSchedule?: boolean) => void; + onClick: (result: ApiBotInlineMediaResult, isSilent?: boolean, shouldSchedule?: boolean) => void; isCurrentUserPremium?: boolean; }; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 50665ca48..7f4059c42 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -201,9 +201,9 @@ type MessagePositionProperties = { type OwnProps = { message: ApiMessage; - observeIntersectionForBottom: ObserveFn; - observeIntersectionForLoading: ObserveFn; - observeIntersectionForPlaying: ObserveFn; + observeIntersectionForBottom?: ObserveFn; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; album?: IAlbum; noAvatars?: boolean; withAvatar?: boolean; @@ -214,9 +214,9 @@ type OwnProps = noReplies: boolean; appearanceOrder: number; isJustAdded: boolean; - memoFirstUnreadIdRef: { current: number | undefined }; - getIsMessageListReady: Signal; - onIntersectPinnedMessage: OnIntersectPinnedMessage; + memoFirstUnreadIdRef?: { current: number | undefined }; + getIsMessageListReady?: Signal; + onIntersectPinnedMessage?: OnIntersectPinnedMessage; } & MessagePositionProperties; @@ -494,7 +494,7 @@ const Message: FC = ({ useUnmountCleanup(() => { if (message.isPinned) { const id = album ? album.mainMessage.id : messageId; - onIntersectPinnedMessage({ viewportPinnedIdsToRemove: [id] }); + onIntersectPinnedMessage?.({ viewportPinnedIdsToRemove: [id] }); } }); @@ -729,7 +729,7 @@ const Message: FC = ({ useEffect(() => { if ((sticker?.hasEffect || effect) && (( - memoFirstUnreadIdRef.current && messageId >= memoFirstUnreadIdRef.current + memoFirstUnreadIdRef?.current && messageId >= memoFirstUnreadIdRef.current ) || isLocal)) { requestEffect(); } @@ -1105,7 +1105,7 @@ const Message: FC = ({ )} )} - {sticker && ( + {sticker && observeIntersectionForLoading && observeIntersectionForPlaying && ( = ({ function renderInvertibleMediaContent(hasCustomAppendix: boolean) { const content = ( <> - {isAlbum && ( + {isAlbum && observeIntersectionForLoading && ( , shouldHandleMouseLeave: boolean, - getIsMessageListReady: Signal, + getIsMessageListReady?: Signal, ) { const { updateDraftReplyInfo, sendDefaultReaction } = getActions(); @@ -146,7 +146,7 @@ export default function useOuterHandlers( } useEffect(() => { - if (!IS_TOUCH_ENV || isInSelectMode || !canReply || isContextMenuShown || !getIsMessageListReady()) { + if (!IS_TOUCH_ENV || isInSelectMode || !canReply || isContextMenuShown || !getIsMessageListReady?.()) { return undefined; } diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index a834821ee..bd5c7ec71 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -28,8 +28,10 @@ import LocationAccessModal from './locationAccess/LocationAccessModal.async'; import MapModal from './map/MapModal.async'; import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async'; import PaidReactionModal from './paidReaction/PaidReactionModal.async'; +import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async'; import ReportAdModal from './reportAd/ReportAdModal.async'; import ReportModal from './reportModal/ReportModal.async'; +import SharePreparedMessageModal from './sharePreparedMessage/SharePreparedMessageModal.async'; import StarsGiftModal from './stars/gift/StarsGiftModal.async'; import StarsBalanceModal from './stars/StarsBalanceModal.async'; import StarsPaymentModal from './stars/StarsPaymentModal.async'; @@ -72,6 +74,8 @@ type ModalKey = keyof Pick; @@ -120,6 +124,8 @@ const MODALS: ModalRegistry = { monetizationVerificationModal: VerificationMonetizationModal, giftWithdrawModal: GiftWithdrawModal, giftStatusInfoModal: GiftStatusInfoModal, + preparedMessageModal: PreparedMessageModal, + sharePreparedMessageModal: SharePreparedMessageModal, giftTransferModal: GiftTransferModal, }; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; diff --git a/src/components/modals/preparedMessage/PreparedMessageModal.async.tsx b/src/components/modals/preparedMessage/PreparedMessageModal.async.tsx new file mode 100644 index 000000000..47c7c877e --- /dev/null +++ b/src/components/modals/preparedMessage/PreparedMessageModal.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teact'; + +import type { OwnProps } from './PreparedMessageModal'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const PreparedMessageModalAsync: FC = (props) => { + const { modal } = props; + const PreparedMessageModal = useModuleLoader(Bundles.Extra, 'PreparedMessageModal', !modal); + + // eslint-disable-next-line react/jsx-props-no-spreading + return PreparedMessageModal ? : undefined; +}; + +export default PreparedMessageModalAsync; diff --git a/src/components/modals/preparedMessage/PreparedMessageModal.module.scss b/src/components/modals/preparedMessage/PreparedMessageModal.module.scss new file mode 100644 index 000000000..d94bc3fda --- /dev/null +++ b/src/components/modals/preparedMessage/PreparedMessageModal.module.scss @@ -0,0 +1,185 @@ +@use "../../../styles/mixins"; + +.root { + :global(.modal-dialog) { + max-width: 26rem; + } +} + +.content { + padding: 0 !important; +} + +.modalTitle { + margin-bottom: 0; +} + +.container { + padding: 1rem; + background-color: var(--color-background-secondary); + border-bottom-left-radius: var(--border-radius-modal); + border-bottom-right-radius: var(--border-radius-modal); +} + +.header { + display: flex; + align-items: center; + padding: 0.5rem; + border-bottom: 0.0625rem solid var(--color-borders); +} + +.actionMessageView { + display: grid; + place-content: center; + min-height: 22.5rem; + height: 100%; + + position: relative; + overflow: hidden; + flex: 0 0 auto; + + margin: 0.75rem; + width: calc(100% - 1.5rem); + + border-radius: var(--border-radius-default); + + background-color: var(--theme-background-color); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + + :global(.Message) { + padding: 1rem; + } + + :global(.message-content) { + max-width: 20rem; + } + + :global(html.theme-light) & { + background-image: url('../../../assets/chat-bg-br.png'); + } + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-image: url('../../../assets/chat-bg-pattern-light.png'); + background-position: top right; + background-size: 510px auto; + background-repeat: repeat; + mix-blend-mode: overlay; + + :global(html.theme-dark) & { + background-image: url('../../../assets/chat-bg-pattern-dark.png'); + mix-blend-mode: unset; + } + + @media (max-width: 600px) { + bottom: auto; + height: calc(var(--vh, 1vh) * 100); + } + } +} + +.info { + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.background { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; + background-color: var(--theme-background-color); + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + } + + :global(html.theme-light) &:not(.customBgImage)::before { + background-image: url('../../../assets/chat-bg-br.png'); + } + + &:not(.customBgImage).customBgColor::before { + display: none; + } + + &.customBgImage::before { + background-image: var(--custom-background) !important; + transform: scale(1.1); + } + + :global(body:not(.no-page-transitions)) &.withTransition { + transition: background-color 0.2s; + + &.customBgImage::before { + transition: background-image var(--layer-transition); + } + } + + &.customBgImage.blurred::before { + filter: blur(12px); + } + + @media screen and (min-width: 1276px) { + :global(body:not(.no-page-transitions)) &:not(.customBgImage)::before { + overflow: hidden; + transform: scale(1); + transform-origin: left center; + } + } + + :global(html.theme-light body:not(.no-page-transitions)) &:not(.customBgImage).withRightColumn::before { + @media screen and (min-width: 1276px) { + transform: scaleX(0.73) !important; + } + + @media screen and (min-width: 1921px) { + transform: scaleX(0.8) !important; + } + + @media screen and (min-width: 2600px) { + transform: scaleX(0.95) !important; + } + } + + /* stylelint-disable-next-line @stylistic/max-line-length */ + :global(html.theme-light body:not(.no-page-transitions)) &:not(.customBgImage).withRightColumn.withTransition::before { + transition: transform var(--layer-transition); + } + + &:not(.customBgImage):not(.customBgColor)::after { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-image: url('../../../assets/chat-bg-pattern-light.png'); + background-position: top right; + background-size: 510px auto; + background-repeat: repeat; + mix-blend-mode: overlay; + + :global(html.theme-dark) & { + background-image: url('../../../assets/chat-bg-pattern-dark.png'); + mix-blend-mode: unset; + } + } +} diff --git a/src/components/modals/preparedMessage/PreparedMessageModal.tsx b/src/components/modals/preparedMessage/PreparedMessageModal.tsx new file mode 100644 index 000000000..0b2c358c5 --- /dev/null +++ b/src/components/modals/preparedMessage/PreparedMessageModal.tsx @@ -0,0 +1,198 @@ +import React, { + type FC, + memo, useMemo, useRef, +} from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { ApiUser } from '../../../api/types'; +import type { TabState } from '../../../global/types'; +import type { ThemeKey } from '../../../types'; +import { MAIN_THREAD_ID } from '../../../api/types'; + +import { getMockPreparedMessageFromResult, getUserFullName } from '../../../global/helpers'; +import { selectTheme, selectUser } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import buildStyle from '../../../util/buildStyle'; + +import useCustomBackground from '../../../hooks/useCustomBackground'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Icon from '../../common/icons/Icon'; +import Message from '../../middle/message/Message'; +import Button from '../../ui/Button'; +import Modal from '../../ui/Modal'; + +import styles from './PreparedMessageModal.module.scss'; + +export type OwnProps = { + modal: TabState['preparedMessageModal']; +}; + +type StateProps = { + theme: ThemeKey; + isBackgroundBlurred?: boolean; + patternColor?: string; + customBackground?: string; + backgroundColor?: string; + bot?: ApiUser; +}; + +const PreparedMessageModal: FC = ({ + modal, + theme, + isBackgroundBlurred, + patternColor, + customBackground, + backgroundColor, + bot, +}) => { + const { + closePreparedInlineMessageModal, sendWebAppEvent, openSharePreparedMessageModal, + } = getActions(); + const lang = useLang(); + const isOpen = Boolean(modal); + + const { webAppKey, message, botId } = modal || {}; + + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + + const customBackgroundValue = useCustomBackground(theme, customBackground); + + const handleOpenClick = useLastCallback(() => { + if (webAppKey && botId && message) { + openSharePreparedMessageModal({ + webAppKey, + message, + }); + closePreparedInlineMessageModal(); + } + }); + + const handleCloseClick = useLastCallback(() => { + closePreparedInlineMessageModal(); + if (webAppKey) { + sendWebAppEvent({ + webAppKey, + event: { + eventType: 'prepared_message_failed', + eventData: { error: 'USER_DECLINED' }, + }, + }); + } + }); + + const header = useMemo(() => { + if (!modal) { + return undefined; + } + + return ( +
+ +

+ {lang('BotShareMessage')} +

+
+ ); + }, [lang, modal]); + + const localMessage = useMemo(() => { + if (!botId || !message || !webAppKey) return undefined; + return getMockPreparedMessageFromResult(botId, message); + }, [botId, message, webAppKey]); + + const bgClassName = buildClassName( + styles.background, + styles.withTransition, + customBackground && styles.customBgImage, + backgroundColor && styles.customBgColor, + customBackground && isBackgroundBlurred && styles.blurred, + ); + + return ( + +
+
+ {localMessage && ( + + )} +
+
+

+ {lang('WebAppShareMessageInfo', { user: getUserFullName(bot) })} +

+ +
+ + ); +}; + +export default memo(withGlobal( + (global, { modal }) => { + const theme = selectTheme(global); + const { + isBlurred: isBackgroundBlurred, + patternColor, + background: customBackground, + backgroundColor, + } = global.settings.themes[theme] || {}; + const bot = modal ? selectUser(global, modal?.botId) : undefined; + + return { + theme, + isBackgroundBlurred, + patternColor, + customBackground, + backgroundColor, + bot, + currentUserId: global.currentUserId, + }; + }, +)(PreparedMessageModal)); diff --git a/src/components/modals/sharePreparedMessage/SharePreparedMessageModal.async.tsx b/src/components/modals/sharePreparedMessage/SharePreparedMessageModal.async.tsx new file mode 100644 index 000000000..3da547176 --- /dev/null +++ b/src/components/modals/sharePreparedMessage/SharePreparedMessageModal.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teact'; + +import type { OwnProps } from './SharePreparedMessageModal'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const SharePreparedMessageModalAsync: FC = (props) => { + const { modal } = props; + const SharePreparedMessageModal = useModuleLoader(Bundles.Extra, 'SharePreparedMessageModal', !modal); + + // eslint-disable-next-line react/jsx-props-no-spreading + return SharePreparedMessageModal ? : undefined; +}; + +export default SharePreparedMessageModalAsync; diff --git a/src/components/modals/sharePreparedMessage/SharePreparedMessageModal.tsx b/src/components/modals/sharePreparedMessage/SharePreparedMessageModal.tsx new file mode 100644 index 000000000..f48e8782a --- /dev/null +++ b/src/components/modals/sharePreparedMessage/SharePreparedMessageModal.tsx @@ -0,0 +1,97 @@ +import React, { + type FC, + memo, useEffect, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal } from '../../../global'; + +import type { TabState } from '../../../global/types'; +import type { ThreadId } from '../../../types'; +import { MAIN_THREAD_ID } from '../../../api/types'; + +import { getPeerTitle } from '../../../global/helpers'; +import { selectPeer } from '../../../global/selectors'; + +import useFlag from '../../../hooks/useFlag'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import RecipientPicker from '../../common/RecipientPicker'; + +export type OwnProps = { + modal: TabState['sharePreparedMessageModal']; +}; + +const SharePreparedMessageModal: FC = ({ + modal, +}) => { + const { + closeSharePreparedMessageModal, + sendInlineBotResult, + sendWebAppEvent, + showNotification, + } = getActions(); + const lang = useOldLang(); + const isOpen = Boolean(modal); + + const [isShown, markIsShown, unmarkIsShown] = useFlag(); + useEffect(() => { + if (isOpen) { + markIsShown(); + } + }, [isOpen, markIsShown]); + + const { message, filter, webAppKey } = modal || {}; + + const handleClose = useLastCallback(() => { + closeSharePreparedMessageModal(); + if (webAppKey) { + sendWebAppEvent({ + webAppKey, + event: { + eventType: 'prepared_message_failed', + eventData: { error: 'USER_DECLINED' }, + }, + }); + } + }); + + const handleSelectRecipient = useLastCallback((id: string, threadId?: ThreadId) => { + if (message && webAppKey) { + const global = getGlobal(); + const peer = selectPeer(global, id); + sendInlineBotResult({ + chatId: id, + threadId: threadId || MAIN_THREAD_ID, + id: message.result.id, + queryId: message.result.queryId, + }); + sendWebAppEvent({ + webAppKey, + event: { + eventType: 'prepared_message_sent', + }, + }); + showNotification({ + message: lang('BotSharedToOne', getPeerTitle(lang, peer!)), + }); + closeSharePreparedMessageModal(); + } + }); + + if (!isOpen && !isShown) { + return undefined; + } + + return ( + + ); +}; + +export default memo(SharePreparedMessageModal); diff --git a/src/components/modals/webApp/WebAppModalTabContent.tsx b/src/components/modals/webApp/WebAppModalTabContent.tsx index caef27433..27a737a2d 100644 --- a/src/components/modals/webApp/WebAppModalTabContent.tsx +++ b/src/components/modals/webApp/WebAppModalTabContent.tsx @@ -148,6 +148,7 @@ const WebAppModalTabContent: FC = ({ openLocationAccessModal, changeWebAppModalState, closeWebAppModal, + openPreparedInlineMessageModal, } = getActions(); const [mainButton, setMainButton] = useState(); const [secondaryButton, setSecondaryButton] = useState(); @@ -698,6 +699,12 @@ const WebAppModalTabContent: FC = ({ handleCheckDownloadFile(eventData.url, eventData.file_name); } + if (eventType === 'web_app_send_prepared_message') { + if (!bot || !webAppKey) return; + const { id } = eventData; + openPreparedInlineMessageModal({ botId: bot.id, messageId: id, webAppKey }); + } + if (eventType === 'web_app_request_emoji_status_access') { if (!bot) return; openEmojiStatusAccessModal({ bot, webAppKey }); diff --git a/src/config.ts b/src/config.ts index 46dd901b6..5422d695b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -312,7 +312,7 @@ export const LANG_PACK = 'weba'; // eslint-disable-next-line max-len export const COUNTRIES_WITH_12H_TIME_FORMAT = new Set(['AU', 'BD', 'CA', 'CO', 'EG', 'HN', 'IE', 'IN', 'JO', 'MX', 'MY', 'NI', 'NZ', 'PH', 'PK', 'SA', 'SV', 'US']); -export const API_CHAT_TYPES = ['bots', 'channels', 'chats', 'users'] as const; +export const API_CHAT_TYPES = ['bots', 'channels', 'chats', 'users', 'groups'] as const; export const HEART_REACTION: ApiReactionEmoji = { type: 'emoji', diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index dad2c31f7..65c8e57e9 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -381,14 +381,13 @@ addActionHandler('switchBotInline', (global, actions, payload): ActionReturnType addActionHandler('sendInlineBotResult', (global, actions, payload): ActionReturnType => { const { - id, queryId, isSilent, scheduledAt, messageList, + id, queryId, isSilent, scheduledAt, threadId, chatId, tabId = getCurrentTabId(), } = payload; if (!id) { return; } - const { chatId, threadId } = messageList; const chat = selectChat(global, chatId)!; const draftReplyInfo = selectDraft(global, chatId, threadId)?.replyInfo; diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 78d4525dd..f5dc2886d 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -1,6 +1,7 @@ import type { ApiAttachment, ApiChat, + ApiChatType, ApiDraft, ApiError, ApiInputMessageReplyInfo, @@ -2342,6 +2343,59 @@ addActionHandler('reportMessageDelivery', (global, actions, payload): ActionRetu } }); +addActionHandler('openPreparedInlineMessageModal', async (global, actions, payload): Promise => { + const { + botId, messageId, webAppKey, tabId = getCurrentTabId(), + } = payload; + + const bot = selectUser(global, botId); + if (!bot) return; + + const result = await callApi('fetchPreparedInlineMessage', { + bot, + id: messageId, + }); + if (!result) { + actions.sendWebAppEvent({ + webAppKey, + event: { + eventType: 'prepared_message_failed', + eventData: { error: 'MESSAGE_EXPIRED' }, + }, + tabId, + }); + return; + } + + global = getGlobal(); + global = updateTabState(global, { + preparedMessageModal: { + message: result, + webAppKey, + botId, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('openSharePreparedMessageModal', (global, actions, payload): ActionReturnType => { + const { + webAppKey, message, tabId = getCurrentTabId(), + } = payload; + + const supportedFilters = message.peerTypes?.filter((type): type is ApiChatType => type !== 'self'); + + global = getGlobal(); + global = updateTabState(global, { + sharePreparedMessageModal: { + webAppKey, + filter: supportedFilters, + message, + }, + }, tabId); + setGlobal(global); +}); + function countSortedIds(ids: number[], from: number, to: number) { // If ids are outside viewport, we cannot get correct count if (ids.length === 0 || from < ids[0] || to > ids[ids.length - 1]) return undefined; diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 2eafde4a1..acf350c0f 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -1077,3 +1077,19 @@ addActionHandler('closeAboutAdsModal', (global, actions, payload): ActionReturnT aboutAdsModal: undefined, }, tabId); }); + +addActionHandler('closePreparedInlineMessageModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + preparedMessageModal: undefined, + }, tabId); +}); + +addActionHandler('closeSharePreparedMessageModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + sharePreparedMessageModal: undefined, + }, tabId); +}); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index 4ee8820bc..2042919a8 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -5,7 +5,9 @@ import type { ApiChatFolder, ApiChatFullInfo, ApiChatInviteInfo, + ApiMessage, ApiPeer, + ApiPreparedInlineMessage, ApiTopic, ApiUser, } from '../../api/types'; @@ -22,6 +24,7 @@ import { VERIFICATION_CODES_USER_ID, } from '../../config'; import { formatDateToString, formatTime } from '../../util/dates/dateFormat'; +import { getServerTime } from '../../util/serverTime'; import { getGlobal } from '..'; import { isSystemBot } from './bots'; import { getMainUsername, getUserFirstOrLastName } from './users'; @@ -474,3 +477,19 @@ export function getCustomPeerFromInvite(invite: ApiChatInviteInfo): CustomPeer { fakeType: isFake ? 'fake' : isScam ? 'scam' : undefined, }; } + +export function getMockPreparedMessageFromResult(botId: string, preparedMessage: ApiPreparedInlineMessage) { + const { result } = preparedMessage; + + const inlineButtons = result?.sendMessage?.replyMarkup?.inlineButtons; + + return { + chatId: botId, + content: result.sendMessage.content, + date: getServerTime(), + id: 0, + isOutgoing: true, + viaBotId: botId, + inlineButtons, + } satisfies ApiMessage; +} diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index b15b872f6..ef36b17a4 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -28,6 +28,7 @@ import type { ApiPaymentStatus, ApiPhoto, ApiPremiumSection, + ApiPreparedInlineMessage, ApiPrivacyKey, ApiPrivacySettings, ApiReaction, @@ -528,6 +529,17 @@ export interface ActionPayloads { chatId: string; } & WithTabId; closeAboutAdsModal: WithTabId | undefined; + openPreparedInlineMessageModal: { + botId: string; + messageId: string; + webAppKey: string; + } & WithTabId; + closePreparedInlineMessageModal: WithTabId | undefined; + openSharePreparedMessageModal: { + webAppKey: string; + message: ApiPreparedInlineMessage; + } & WithTabId; + closeSharePreparedMessageModal: WithTabId | undefined; openPreviousReportAdModal: WithTabId | undefined; openPreviousReportModal: WithTabId | undefined; closeReportAdModal: WithTabId | undefined; @@ -1872,7 +1884,8 @@ export interface ActionPayloads { sendInlineBotResult: { id: string; queryId: string; - messageList: MessageList; + chatId: string; + threadId: ThreadId; isSilent?: boolean; scheduledAt?: number; } & WithTabId; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 8406d630a..5fbb4f1d8 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -30,6 +30,7 @@ import type { ApiPremiumGiftCodeOption, ApiPremiumPromo, ApiPremiumSection, + ApiPreparedInlineMessage, ApiReactionWithPaid, ApiReceiptRegular, ApiSavedGifts, @@ -499,6 +500,18 @@ export type TabState = { isQuiz?: boolean; }; + preparedMessageModal?: { + message: ApiPreparedInlineMessage; + webAppKey: string; + botId: string; + }; + + sharePreparedMessageModal?: { + webAppKey: string; + message: ApiPreparedInlineMessage; + filter: ApiChatType[]; + }; + webApps: { activeWebAppKey?: string; openedOrderedKeys: string[]; diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index 2dcb94862..413f0b226 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1624,6 +1624,7 @@ messages.viewSponsoredMessage#673ad8f1 peer:InputPeer random_id:bytes = Bool; messages.clickSponsoredMessage#f093465 flags:# media:flags.0?true fullscreen:flags.1?true peer:InputPeer random_id:bytes = Bool; messages.reportSponsoredMessage#1af3dbb8 peer:InputPeer random_id:bytes option:bytes = channels.SponsoredMessageReportResult; messages.getSponsoredMessages#9bd2f439 peer:InputPeer = messages.SponsoredMessages; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = 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; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 6fa2c5dea..ba8fa637b 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -219,6 +219,7 @@ "messages.reportSponsoredMessage", "messages.getSponsoredMessages", "messages.reportMessagesDelivery", + "messages.getPreparedInlineMessage", "updates.getState", "updates.getDifference", "updates.getChannelDifference", diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 7af33f8b3..610179d64 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -483,6 +483,8 @@ export interface LangPair { 'SetAdditionalPasswordInfo': undefined; 'EditAdminTransferSetPassword': undefined; 'BotOpenPageTitle': undefined; + 'BotShareMessageShare': undefined; + 'BotShareMessage': undefined; 'FilterDeleteAlert': undefined; 'RequestToJoinChannelSentDescription': undefined; 'RequestToJoinGroupSentDescription': undefined; @@ -1547,6 +1549,9 @@ export interface LangPairWithVariables { 'ConversationOpenBotLinkAllowMessages': { 'bot': V; }; + 'WebAppShareMessageInfo': { + 'user': V; + }; 'BlockUserTitle': { 'user': V; }; @@ -2216,6 +2221,9 @@ export interface LangPairWithVariables { 'UniqueStatusWearTitle': { 'gift': V; }; + 'BotSharedToOne': { + 'peer': V; + }; } export interface LangPairPlural { diff --git a/src/types/webapp.ts b/src/types/webapp.ts index 65d648984..7f745387e 100644 --- a/src/types/webapp.ts +++ b/src/types/webapp.ts @@ -137,6 +137,9 @@ export type WebAppInboundEvent = url: string; file_name: string; }> | + WebAppEvent<'web_app_send_prepared_message', { + id: string; + }> | WebAppEvent<'web_app_request_viewport' | 'web_app_request_theme' | 'web_app_ready' | 'web_app_expand' | 'web_app_request_phone' | 'web_app_close' | 'web_app_close_scan_qr_popup' | 'web_app_request_write_access' | 'iframe_will_reload' @@ -259,6 +262,10 @@ export type WebAppOutboundEvent = WebAppEvent<'file_download_requested', { status: 'cancelled' | 'downloading'; }> | + WebAppEvent<'prepared_message_failed', { + error: 'UNSUPPORTED' | 'MESSAGE_EXPIRED' | 'MESSAGE_SEND_FAILED' + | 'USER_DECLINED' | 'UNKNOWN_ERROR'; + }> | WebAppEvent<'main_button_pressed' | 'secondary_button_pressed' | 'back_button_pressed' | 'settings_button_pressed' | 'scan_qr_popup_closed' - | 'reload_iframe' | 'emoji_status_set', null>; + | 'reload_iframe' | 'prepared_message_sent' | 'emoji_status_set', null>; diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index 71dd65540..7ef96e243 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -231,6 +231,11 @@ export function formatShareText(url?: string, text?: string, title?: string): Ap function parseChooseParameter(choose?: string) { if (!choose) return undefined; - const types = choose.toLowerCase().split(' '); + const types = choose.toLowerCase().split(' ').flatMap((type) => { + if (type === 'groups') { + return ['chats', 'groups']; + } + return [type]; + }); return types.filter((type): type is ApiChatType => API_CHAT_TYPES.includes(type as ApiChatType)); }