Mini Apps: Implement Share Support (#5576)

This commit is contained in:
Alexander Zinchuk 2025-03-01 17:59:51 +01:00
parent c8f82b1f91
commit 87fc3a832f
36 changed files with 1085 additions and 215 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {

View File

@ -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.";

View File

@ -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';

View File

@ -1125,10 +1125,11 @@ const Composer: FC<OwnProps & StateProps> = ({
const { id, queryId, isSilent } = args;
sendInlineBotResult({
id,
chatId,
threadId,
queryId,
scheduledAt,
isSilent,
messageList,
});
return;
}
@ -1272,8 +1273,9 @@ const Composer: FC<OwnProps & StateProps> = ({
sendInlineBotResult({
id: inlineResult.id,
queryId: inlineResult.queryId,
threadId,
chatId,
isSilent,
messageList: currentMessageList!,
});
}

View File

@ -77,7 +77,7 @@ const RecipientPicker: FC<OwnProps & StateProps> = ({
const chatFullInfo = selectChatFullInfo(global, id);
return chat && chatFullInfo && getCanPostInChat(chat, undefined, undefined, chatFullInfo);
return chat && (!chatFullInfo || getCanPostInChat(chat, undefined, undefined, chatFullInfo));
});
const sorted = sortChatIds(

View File

@ -154,7 +154,8 @@ const AttachMenu: FC<OwnProps> = ({
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);

View File

@ -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<OwnProps> = ({ 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);
});

View File

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

View File

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

View File

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

View File

@ -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<boolean>;
onIntersectPinnedMessage: OnIntersectPinnedMessage;
memoFirstUnreadIdRef?: { current: number | undefined };
getIsMessageListReady?: Signal<boolean>;
onIntersectPinnedMessage?: OnIntersectPinnedMessage;
}
& MessagePositionProperties;
@ -494,7 +494,7 @@ const Message: FC<OwnProps & StateProps> = ({
useUnmountCleanup(() => {
if (message.isPinned) {
const id = album ? album.mainMessage.id : messageId;
onIntersectPinnedMessage({ viewportPinnedIdsToRemove: [id] });
onIntersectPinnedMessage?.({ viewportPinnedIdsToRemove: [id] });
}
});
@ -729,7 +729,7 @@ const Message: FC<OwnProps & StateProps> = ({
useEffect(() => {
if ((sticker?.hasEffect || effect) && ((
memoFirstUnreadIdRef.current && messageId >= memoFirstUnreadIdRef.current
memoFirstUnreadIdRef?.current && messageId >= memoFirstUnreadIdRef.current
) || isLocal)) {
requestEffect();
}
@ -1105,7 +1105,7 @@ const Message: FC<OwnProps & StateProps> = ({
)}
</div>
)}
{sticker && (
{sticker && observeIntersectionForLoading && observeIntersectionForPlaying && (
<Sticker
message={message}
observeIntersection={observeIntersectionForLoading}
@ -1383,7 +1383,7 @@ const Message: FC<OwnProps & StateProps> = ({
function renderInvertibleMediaContent(hasCustomAppendix: boolean) {
const content = (
<>
{isAlbum && (
{isAlbum && observeIntersectionForLoading && (
<Album
album={album!}
albumLayout={albumLayout!}

View File

@ -36,7 +36,7 @@ export default function useOuterHandlers(
isContextMenuShown: boolean,
quickReactionRef: RefObject<HTMLDivElement>,
shouldHandleMouseLeave: boolean,
getIsMessageListReady: Signal<boolean>,
getIsMessageListReady?: Signal<boolean>,
) {
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;
}

View File

@ -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<TabState,
'giftUpgradeModal' |
'monetizationVerificationModal' |
'giftWithdrawModal' |
'preparedMessageModal' |
'sharePreparedMessageModal' |
'giftStatusInfoModal' |
'giftTransferModal'
>;
@ -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[];

View File

@ -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<OwnProps> = (props) => {
const { modal } = props;
const PreparedMessageModal = useModuleLoader(Bundles.Extra, 'PreparedMessageModal', !modal);
// eslint-disable-next-line react/jsx-props-no-spreading
return PreparedMessageModal ? <PreparedMessageModal {...props} /> : undefined;
};
export default PreparedMessageModalAsync;

View File

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

View File

@ -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<OwnProps & StateProps> = ({
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<HTMLDivElement>(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 (
<div className={styles.header}>
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Close')}
onClick={handleCloseClick}
>
<Icon name="close" />
</Button>
<h3 className={buildClassName('modal-title', styles.modalTitle)}>
{lang('BotShareMessage')}
</h3>
</div>
);
}, [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 (
<Modal
dialogRef={containerRef}
isOpen={isOpen}
header={header}
onClose={handleCloseClick}
className={styles.root}
contentClassName={styles.content}
>
<div
className={buildClassName(styles.actionMessageView, 'MessageList')}
// @ts-ignore -- FIXME: Find a way to disable interactions but keep a11y
inert
style={buildStyle(
`--pattern-color: ${patternColor}`,
backgroundColor && `--theme-background-color: ${backgroundColor}`,
)}
>
<div
className={bgClassName}
style={customBackgroundValue ? `--custom-background: ${customBackgroundValue}` : undefined}
/>
{localMessage && (
<Message
key={botId}
message={localMessage}
threadId={MAIN_THREAD_ID}
messageListType="thread"
noComments
noReplies
appearanceOrder={0}
isJustAdded={false}
isFirstInGroup
isLastInGroup
isLastInList={false}
isFirstInDocumentGroup={false}
isLastInDocumentGroup={false}
/>
)}
</div>
<div className={styles.container}>
<p className={styles.info}>
{lang('WebAppShareMessageInfo', { user: getUserFullName(bot) })}
</p>
<Button
size="smaller"
onClick={handleOpenClick}
>
{lang('BotShareMessageShare')}
</Button>
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(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));

View File

@ -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<OwnProps> = (props) => {
const { modal } = props;
const SharePreparedMessageModal = useModuleLoader(Bundles.Extra, 'SharePreparedMessageModal', !modal);
// eslint-disable-next-line react/jsx-props-no-spreading
return SharePreparedMessageModal ? <SharePreparedMessageModal {...props} /> : undefined;
};
export default SharePreparedMessageModalAsync;

View File

@ -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<OwnProps> = ({
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 (
<RecipientPicker
isOpen={isOpen}
searchPlaceholder={lang('Search')}
filter={filter}
onSelectRecipient={handleSelectRecipient}
onClose={handleClose}
onCloseAnimationEnd={unmarkIsShown}
/>
);
};
export default memo(SharePreparedMessageModal);

View File

@ -148,6 +148,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
openLocationAccessModal,
changeWebAppModalState,
closeWebAppModal,
openPreparedInlineMessageModal,
} = getActions();
const [mainButton, setMainButton] = useState<WebAppButton | undefined>();
const [secondaryButton, setSecondaryButton] = useState<WebAppButton | undefined>();
@ -698,6 +699,12 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
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 });

View File

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

View File

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

View File

@ -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<void> => {
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;

View File

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

View File

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

View File

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

View File

@ -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[];

View File

@ -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<int> = 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;

View File

@ -219,6 +219,7 @@
"messages.reportSponsoredMessage",
"messages.getSponsoredMessages",
"messages.reportMessagesDelivery",
"messages.getPreparedInlineMessage",
"updates.getState",
"updates.getDifference",
"updates.getChannelDifference",

View File

@ -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<V extends unknown = LangVariable> {
'ConversationOpenBotLinkAllowMessages': {
'bot': V;
};
'WebAppShareMessageInfo': {
'user': V;
};
'BlockUserTitle': {
'user': V;
};
@ -2216,6 +2221,9 @@ export interface LangPairWithVariables<V extends unknown = LangVariable> {
'UniqueStatusWearTitle': {
'gift': V;
};
'BotSharedToOne': {
'peer': V;
};
}
export interface LangPairPlural {

View File

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

View File

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