From c3fd0d66e951ec3ce2dd1338339392729877e6de Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 16 Jul 2021 17:44:24 +0300 Subject: [PATCH] Composer: Implement Inline Bots (#1206) --- src/api/gramjs/apiBuilders/bots.ts | 51 ++++ src/api/gramjs/apiBuilders/symbols.ts | 21 +- src/api/gramjs/apiBuilders/users.ts | 1 + src/api/gramjs/localDb.ts | 2 + src/api/gramjs/methods/bots.ts | 145 ++++++++++- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/media.ts | 22 +- src/api/types/bots.ts | 40 ++++ src/api/types/index.ts | 1 + src/api/types/media.ts | 1 + src/api/types/messages.ts | 4 +- src/api/types/users.ts | 1 + src/bundles/extra.ts | 1 + src/components/common/GifButton.tsx | 8 +- src/components/left/main/Badge.scss | 3 +- src/components/main/Main.tsx | 11 +- .../middle/composer/AttachmentModal.tsx | 9 +- src/components/middle/composer/Composer.scss | 16 +- src/components/middle/composer/Composer.tsx | 100 ++++++-- .../composer/InlineBotTooltip.async.tsx | 15 ++ .../middle/composer/InlineBotTooltip.scss | 54 +++++ .../middle/composer/InlineBotTooltip.tsx | 225 ++++++++++++++++++ .../middle/composer/MentionTooltip.scss | 1 - .../middle/composer/MentionTooltip.tsx | 72 ++---- .../middle/composer/MessageInput.tsx | 10 +- .../middle/composer/hooks/useEmojiTooltip.ts | 9 +- .../composer/hooks/useInlineBotTooltip.ts | 88 +++++++ .../composer/hooks/useMentionTooltip.ts | 86 ++++--- .../composer/inlineResults/ArticleResult.tsx | 33 +++ .../composer/inlineResults/BaseResult.scss | 62 +++++ .../composer/inlineResults/BaseResult.tsx | 62 +++++ .../composer/inlineResults/GifResult.tsx | 40 ++++ .../composer/inlineResults/MediaResult.scss | 16 ++ .../composer/inlineResults/MediaResult.tsx | 76 ++++++ .../composer/inlineResults/StickerResult.scss | 22 ++ .../composer/inlineResults/StickerResult.tsx | 36 +++ src/components/middle/message/Message.tsx | 4 +- .../middle/message/_message-content.scss | 31 ++- src/components/right/GifSearch.tsx | 1 - src/config.ts | 1 + src/global/cache.ts | 1 + src/global/initial.ts | 7 + src/global/types.ts | 15 +- src/lib/gramjs/client/TelegramClient.js | 39 ++- src/lib/gramjs/tl/apiTl.js | 2 + src/lib/gramjs/tl/static/api.reduced.tl | 2 + src/modules/actions/api/bots.ts | 206 +++++++++++++++- src/modules/helpers/messages.ts | 2 +- src/modules/reducers/bots.ts | 28 +++ src/types/index.ts | 12 + src/util/cacheApi.ts | 6 +- src/util/focusEditableElement.ts | 10 +- src/util/insertHtmlInSelection.ts | 6 +- src/util/setTooltipItemVisible.ts | 30 +++ 54 files changed, 1587 insertions(+), 162 deletions(-) create mode 100644 src/api/gramjs/apiBuilders/bots.ts create mode 100644 src/api/types/bots.ts create mode 100644 src/components/middle/composer/InlineBotTooltip.async.tsx create mode 100644 src/components/middle/composer/InlineBotTooltip.scss create mode 100644 src/components/middle/composer/InlineBotTooltip.tsx create mode 100644 src/components/middle/composer/hooks/useInlineBotTooltip.ts create mode 100644 src/components/middle/composer/inlineResults/ArticleResult.tsx create mode 100644 src/components/middle/composer/inlineResults/BaseResult.scss create mode 100644 src/components/middle/composer/inlineResults/BaseResult.tsx create mode 100644 src/components/middle/composer/inlineResults/GifResult.tsx create mode 100644 src/components/middle/composer/inlineResults/MediaResult.scss create mode 100644 src/components/middle/composer/inlineResults/MediaResult.tsx create mode 100644 src/components/middle/composer/inlineResults/StickerResult.scss create mode 100644 src/components/middle/composer/inlineResults/StickerResult.tsx create mode 100644 src/modules/reducers/bots.ts create mode 100644 src/util/setTooltipItemVisible.ts diff --git a/src/api/gramjs/apiBuilders/bots.ts b/src/api/gramjs/apiBuilders/bots.ts new file mode 100644 index 000000000..692c45ae5 --- /dev/null +++ b/src/api/gramjs/apiBuilders/bots.ts @@ -0,0 +1,51 @@ +import { Api as GramJs } from '../../../lib/gramjs'; +import { + ApiBotInlineMediaResult, ApiBotInlineResult, ApiInlineResultType, ApiWebDocument, +} from '../../types'; + +import { pick } from '../../../util/iteratees'; +import { buildApiPhoto, buildApiThumbnailFromStripped } from './common'; +import { buildVideoFromDocument } from './messages'; +import { buildStickerFromDocument } from './symbols'; + +export function buildApiBotInlineResult(result: GramJs.BotInlineResult, queryId: string): ApiBotInlineResult { + const { + id, type, title, description, url, thumb, + } = result; + + return { + id, + queryId, + type: type as ApiInlineResultType, + title, + description, + url, + webThumbnail: buildApiWebDocument(thumb), + }; +} + +export function buildApiBotInlineMediaResult( + result: GramJs.BotInlineMediaResult, queryId: string, +): ApiBotInlineMediaResult { + const { + id, type, title, description, photo, document, + } = result; + + return { + id, + queryId, + type: type as ApiInlineResultType, + title, + description, + ...(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), + }), + }; +} + +function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDocument | undefined { + return document ? pick(document, ['url', 'mimeType']) : undefined; +} diff --git a/src/api/gramjs/apiBuilders/symbols.ts b/src/api/gramjs/apiBuilders/symbols.ts index a4e00d51e..e769290a0 100644 --- a/src/api/gramjs/apiBuilders/symbols.ts +++ b/src/api/gramjs/apiBuilders/symbols.ts @@ -5,6 +5,8 @@ import { MEMOJI_STICKER_ID } from '../../../config'; import { buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common'; import localDb from '../localDb'; +const ANIMATED_STICKER_MIME_TYPE = 'application/x-tgsticker'; + export function buildStickerFromDocument(document: GramJs.TypeDocument): ApiSticker | undefined { if (document instanceof GramJs.DocumentEmpty) { return undefined; @@ -15,7 +17,12 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument): ApiStic attr instanceof GramJs.DocumentAttributeSticker )); - if (!stickerAttribute) { + const fileAttribute = document.mimeType === ANIMATED_STICKER_MIME_TYPE && document.attributes + .find((attr: any): attr is GramJs.DocumentAttributeFilename => ( + attr instanceof GramJs.DocumentAttributeFilename + )); + + if (!stickerAttribute && !fileAttribute) { return undefined; } @@ -24,9 +31,11 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument): ApiStic attr instanceof GramJs.DocumentAttributeImageSize )); - const stickerSetInfo = stickerAttribute.stickerset as GramJs.InputStickerSetID; - const emoji = stickerAttribute.alt; - const isAnimated = document.mimeType === 'application/x-tgsticker'; + const stickerSetInfo = stickerAttribute && stickerAttribute.stickerset instanceof GramJs.InputStickerSetID + ? stickerAttribute.stickerset + : undefined; + const emoji = stickerAttribute ? stickerAttribute.alt : undefined; + const isAnimated = document.mimeType === ANIMATED_STICKER_MIME_TYPE; const cachedThumb = document.thumbs && document.thumbs.find( (s): s is GramJs.PhotoCachedSize => s instanceof GramJs.PhotoCachedSize, ); @@ -43,8 +52,8 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument): ApiStic return { id: String(document.id), - stickerSetId: stickerSetInfo.id ? String(stickerSetInfo.id) : MEMOJI_STICKER_ID, - stickerSetAccessHash: String(stickerSetInfo.accessHash), + stickerSetId: stickerSetInfo ? String(stickerSetInfo.id) : MEMOJI_STICKER_ID, + stickerSetAccessHash: stickerSetInfo && String(stickerSetInfo.accessHash), emoji, isAnimated, width, diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 905091e70..b62d2f728 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -43,6 +43,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { status: buildApiUserStatus(mtpUser.status), ...(mtpUser.accessHash && { accessHash: String(mtpUser.accessHash) }), ...(avatarHash && { avatarHash }), + ...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }), }; } diff --git a/src/api/gramjs/localDb.ts b/src/api/gramjs/localDb.ts index b4e350810..91a5ac0f5 100644 --- a/src/api/gramjs/localDb.ts +++ b/src/api/gramjs/localDb.ts @@ -10,6 +10,7 @@ interface LocalDb { documents: Record; stickerSets: Record; photos: Record; + webDocuments: Record; } export default { @@ -20,4 +21,5 @@ export default { documents: {}, stickerSets: {}, photos: {}, + webDocuments: {}, } as LocalDb; diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index ce62eb518..1bc8f34dd 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -1,6 +1,15 @@ -import { invokeRequest } from './client'; +import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; -import { buildInputPeer } from '../gramjsBuilders'; + +import { ApiBotInlineSwitchPm, ApiChat, ApiUser } from '../../types'; + +import localDb from '../localDb'; +import { invokeRequest } from './client'; +import { buildInputPeer, calculateResultHash, generateRandomBigInt } from '../gramjsBuilders'; +import { buildApiUser } from '../apiBuilders/users'; +import { buildApiBotInlineMediaResult, buildApiBotInlineResult } from '../apiBuilders/bots'; +import { buildApiChatFromPreview } from '../apiBuilders/chats'; +import { pick } from '../../../util/iteratees'; export function init() { } @@ -18,3 +27,135 @@ export function answerCallbackButton( data: Buffer.from(data), })); } + +export async function fetchTopInlineBots({ hash = 0 }: { hash?: number }) { + const topPeers = await invokeRequest(new GramJs.contacts.GetTopPeers({ + hash, + botsInline: true, + })); + + if (!(topPeers instanceof GramJs.contacts.TopPeers)) { + return undefined; + } + + const users = topPeers.users.map(buildApiUser).filter(Boolean as any); + const ids = users.map(({ id }) => id); + + return { + hash: calculateResultHash(ids), + ids, + users, + }; +} + +export async function fetchInlineBot({ username }: { username: string }) { + const resolvedPeer = await invokeRequest(new GramJs.contacts.ResolveUsername({ username })); + + if ( + !resolvedPeer + || !( + resolvedPeer.users[0] instanceof GramJs.User + && resolvedPeer.users[0].bot + && resolvedPeer.users[0].botInlinePlaceholder + ) + ) { + return undefined; + } + + addUserToLocalDb(resolvedPeer.users[0]); + + return { + user: buildApiUser(resolvedPeer.users[0]), + chat: buildApiChatFromPreview(resolvedPeer.users[0]), + }; +} + +export async function fetchInlineBotResults({ + bot, chat, query, offset = '', +}: { + bot: ApiUser; chat: ApiChat; query: string; offset?: string; +}) { + const result = await invokeRequest(new GramJs.messages.GetInlineBotResults({ + bot: buildInputPeer(bot.id, bot.accessHash), + peer: buildInputPeer(chat.id, chat.accessHash), + query, + offset, + })); + + if (!result) { + return undefined; + } + + result.users.map(addUserToLocalDb); + + return { + isGallery: Boolean(result.gallery), + help: bot.botPlaceholder, + nextOffset: result.nextOffset, + switchPm: buildSwitchPm(result.switchPm), + users: result.users.map(buildApiUser).filter(Boolean as any), + results: processInlineBotResult(String(result.queryId), result.results), + }; +} + +export async function sendInlineBotResult({ + chat, resultId, queryId, replyingTo, +}: { + chat: ApiChat; + resultId: string; + queryId: string; + replyingTo?: number; +}) { + const randomId = generateRandomBigInt(); + + await invokeRequest(new GramJs.messages.SendInlineBotResult({ + clearDraft: true, + randomId, + queryId: BigInt(queryId), + peer: buildInputPeer(chat.id, chat.accessHash), + id: resultId, + ...(replyingTo && { replyToMsgId: replyingTo }), + }), true); +} + +function buildSwitchPm(switchPm?: GramJs.InlineBotSwitchPM) { + return switchPm ? pick(switchPm, ['text', 'startParam']) as ApiBotInlineSwitchPm : undefined; +} + +function processInlineBotResult(queryId: string, results: GramJs.TypeBotInlineResult[]) { + return results.map((result) => { + 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); + }); +} + +function addUserToLocalDb(user: GramJs.User) { + localDb.users[user.id] = user; +} + +function addDocumentToLocalDb(document: GramJs.Document) { + localDb.documents[String(document.id)] = document; +} + +function addPhotoToLocalDb(photo: GramJs.Photo) { + localDb.photos[String(photo.id)] = photo; +} + +function addWebDocumentToLocalDb(webDocument: GramJs.TypeWebDocument) { + localDb.webDocuments[webDocument.url] = webDocument; +} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 82b77a7b2..a8db1f6a0 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -55,7 +55,7 @@ export { } from './twoFaSettings'; export { - answerCallbackButton, + answerCallbackButton, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults, sendInlineBotResult, } from './bots'; export { diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index 1b59c936c..243a68280 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -17,7 +17,10 @@ import { getEntityTypeById } from '../gramjsBuilders'; import { blobToDataUri } from '../../../util/files'; import * as cacheApi from '../../../util/cacheApi'; -type EntityType = 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet'; +type EntityType = ( + 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' +); +const MEDIA_ENTITY_TYPES = new Set(['msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument']); export default async function downloadMedia( { @@ -70,8 +73,9 @@ async function download( end?: number, mediaFormat?: ApiMediaFormat, ) { - // eslint-disable-next-line max-len - const mediaMatch = url.match(/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|file)([-\d\w./]+)(\?size=\w+)?/); + const mediaMatch = url.startsWith('webDocument') + ? url.match(/(webDocument):(.+)/) + : url.match(/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|file)([-\d\w./]+)(\?size=\w+)?/); if (!mediaMatch) { return undefined; } @@ -91,14 +95,14 @@ async function download( const sizeType = mediaMatch[3] ? mediaMatch[3].replace('?size=', '') : undefined; let entity: ( GramJs.User | GramJs.Chat | GramJs.Channel | GramJs.Photo | - GramJs.Message | GramJs.Document | GramJs.StickerSet | undefined + GramJs.Message | GramJs.Document | GramJs.StickerSet | GramJs.TypeWebDocument | undefined ); if (mediaMatch[1] === 'avatar' || mediaMatch[1] === 'profile') { entityType = getEntityTypeById(Number(entityId)); entityId = Math.abs(Number(entityId)); } else { - entityType = mediaMatch[1] as 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'stickerSet' | 'photo'; + entityType = mediaMatch[1] as 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'stickerSet' | 'photo' | 'webDocument'; } switch (entityType) { @@ -123,13 +127,16 @@ async function download( case 'stickerSet': entity = localDb.stickerSets[entityId as string]; break; + case 'webDocument': + entity = localDb.webDocuments[entityId as string]; + break; } if (!entity) { return undefined; } - if (['msg', 'sticker', 'gif', 'wallpaper', 'photo'].includes(entityType)) { + if (MEDIA_ENTITY_TYPES.has(entityType)) { if (mediaFormat === ApiMediaFormat.Stream) { onProgress!.acceptsBuffer = true; } @@ -154,6 +161,8 @@ async function download( mimeType = 'image/jpeg'; } else if (entityType === 'sticker' && sizeType) { mimeType = 'image/webp'; + } else if (entityType === 'webDocument') { + mimeType = (entity as GramJs.TypeWebDocument).mimeType; } else { mimeType = (entity as GramJs.Document).mimeType; fullSize = (entity as GramJs.Document).size; @@ -230,7 +239,6 @@ function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia { return mediaData; } - function getMimeType(data: Uint8Array, fallbackMimeType = 'image/jpeg') { if (data.length < 4) { return fallbackMimeType; diff --git a/src/api/types/bots.ts b/src/api/types/bots.ts new file mode 100644 index 000000000..06d72c52d --- /dev/null +++ b/src/api/types/bots.ts @@ -0,0 +1,40 @@ +import { + ApiPhoto, ApiSticker, ApiThumbnail, ApiVideo, +} from './messages'; + +export type ApiInlineResultType = ( + 'article' | 'audio' | 'contact' | 'document' | 'game' | 'gif' | 'location' | 'mpeg4_gif' | + 'photo' | 'sticker'| 'venue' | 'video' | 'voice' +); + +export interface ApiWebDocument { + url: string; + mimeType: string; +} + +export interface ApiBotInlineResult { + id: string; + queryId: string; + type: ApiInlineResultType; + title?: string; + description?: string; + url?: string; + webThumbnail?: ApiWebDocument; +} + +export interface ApiBotInlineMediaResult { + id: string; + queryId: string; + type: ApiInlineResultType; + title?: string; + description?: string; + sticker?: ApiSticker; + photo?: ApiPhoto; + gif?: ApiVideo; + thumbnail?: ApiThumbnail; +} + +export interface ApiBotInlineSwitchPm { + text: string; + startParam: string; +} diff --git a/src/api/types/index.ts b/src/api/types/index.ts index 8e2aae4d4..b7c320395 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -5,4 +5,5 @@ export * from './updates'; export * from './media'; export * from './payments'; export * from './settings'; +export * from './bots'; export * from './misc'; diff --git a/src/api/types/media.ts b/src/api/types/media.ts index 0ca5c6521..77e42cfce 100644 --- a/src/api/types/media.ts +++ b/src/api/types/media.ts @@ -1,5 +1,6 @@ // We cache avatars as Data URI for faster initial load // and messages media as Blob for smaller size. + export enum ApiMediaFormat { DataUri, BlobUrl, diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 28c5c36ca..72e20196c 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -21,8 +21,8 @@ export interface ApiPhoto { export interface ApiSticker { id: string; stickerSetId: string; - stickerSetAccessHash: string; - emoji: string; + stickerSetAccessHash?: string; + emoji?: string; isAnimated: boolean; width?: number; height?: number; diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 5826930dc..c6573bc47 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -15,6 +15,7 @@ export interface ApiUser { accessHash?: string; avatarHash?: string; photos?: ApiPhoto[]; + botPlaceholder?: string; canBeInvitedToGroup?: boolean; // Obtained from GetFullUser / UserFullInfo diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 14517d712..f5bef6936 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -36,6 +36,7 @@ export { default as CustomSendMenu } from '../components/middle/composer/CustomS export { default as DropArea } from '../components/middle/composer/DropArea'; export { default as TextFormatter } from '../components/middle/composer/TextFormatter'; export { default as EmojiTooltip } from '../components/middle/composer/EmojiTooltip'; +export { default as InlineBotTooltip } from '../components/middle/composer/InlineBotTooltip'; export { default as RightSearch } from '../components/right/RightSearch'; export { default as StickerSearch } from '../components/right/StickerSearch'; diff --git a/src/components/common/GifButton.tsx b/src/components/common/GifButton.tsx index 293600531..f8cdb8816 100644 --- a/src/components/common/GifButton.tsx +++ b/src/components/common/GifButton.tsx @@ -20,11 +20,12 @@ type OwnProps = { gif: ApiVideo; observeIntersection: ObserveFn; isDisabled?: boolean; + className?: string; onClick: (gif: ApiVideo) => void; }; const GifButton: FC = ({ - gif, observeIntersection, isDisabled, onClick, + gif, observeIntersection, isDisabled, className, onClick, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -53,17 +54,18 @@ const GifButton: FC = ({ [onClick, gif, videoData], ); - const className = buildClassName( + const fullClassName = buildClassName( 'GifButton', gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal', transitionClassNames, localMediaHash, + className, ); return (
{hasThumbnail && ( diff --git a/src/components/left/main/Badge.scss b/src/components/left/main/Badge.scss index ac702edb8..387f6a1ed 100644 --- a/src/components/left/main/Badge.scss +++ b/src/components/left/main/Badge.scss @@ -18,10 +18,9 @@ .Badge-wrapper { display: flex; - margin-left: 1.5rem; .Badge { - margin-left: 0.5rem; + margin-inline-start: .5rem; } } diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index b41cf65c7..8c18266cf 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -53,7 +53,8 @@ type StateProps = { }; type DispatchProps = Pick; const ANIMATION_DURATION = 350; @@ -81,6 +82,7 @@ const Main: FC = ({ loadNotificationSettings, loadNotificationExceptions, updateIsOnline, + loadTopInlineBots, }) => { if (DEBUG && !DEBUG_isLogged) { DEBUG_isLogged = true; @@ -95,8 +97,12 @@ const Main: FC = ({ loadAnimatedEmojis(); loadNotificationSettings(); loadNotificationExceptions(); + loadTopInlineBots(); } - }, [lastSyncTime, loadAnimatedEmojis, loadNotificationExceptions, loadNotificationSettings, updateIsOnline]); + }, [ + lastSyncTime, loadAnimatedEmojis, loadNotificationExceptions, loadNotificationSettings, updateIsOnline, + loadTopInlineBots, + ]); const { transitionClassNames: middleColumnTransitionClassNames, @@ -234,5 +240,6 @@ export default memo(withGlobal( }, (setGlobal, actions): DispatchProps => pick(actions, [ 'loadAnimatedEmojis', 'loadNotificationSettings', 'loadNotificationExceptions', 'updateIsOnline', + 'loadTopInlineBots', ]), )(Main)); diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 7787a4924..94e33741d 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -25,7 +25,6 @@ import './AttachmentModal.scss'; export type OwnProps = { attachments: ApiAttachment[]; caption: string; - canSuggestMembers?: boolean; canSuggestEmoji?: boolean; currentUserId?: number; groupChatMembers?: ApiChatMember[]; @@ -45,7 +44,6 @@ const DROP_LEAVE_TIMEOUT_MS = 150; const AttachmentModal: FC = ({ attachments, caption, - canSuggestMembers, groupChatMembers, currentUserId, usersById, @@ -70,13 +68,14 @@ const AttachmentModal: FC = ({ const { isMentionTooltipOpen, mentionFilter, closeMentionTooltip, insertMention, - mentionFilteredMembers, + mentionFilteredUsers, } = useMentionTooltip( - canSuggestMembers && isOpen, + isOpen, caption, onCaptionUpdate, EDITABLE_INPUT_MODAL_ID, groupChatMembers, + undefined, currentUserId, usersById, ); @@ -228,7 +227,7 @@ const AttachmentModal: FC = ({ onClose={closeMentionTooltip} filter={mentionFilter} onInsertUserName={insertMention} - filteredChatMembers={mentionFilteredMembers} + filteredUsers={mentionFilteredUsers} usersById={usersById} /> .Spinner { + align-self: center; + --spinner-size: 1.5rem; + } + > .Button { flex-shrink: 0; background: none !important; @@ -275,6 +280,7 @@ } } + .forced-placeholder, .placeholder-text { position: absolute; bottom: .9375rem; @@ -284,9 +290,17 @@ text-align: initial; @media (max-width: 600px) { - bottom: 0.6875rem; + bottom: .625rem; } + } + .forced-placeholder { + z-index: var(--z-below); + left: 0; + white-space: nowrap; + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; } &[dir=rtl] .placeholder-text { diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index 056b9d8d7..2b21abd2e 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -6,6 +6,8 @@ import { withGlobal } from '../../../lib/teact/teactn'; import { GlobalActions, GlobalState, MessageListType } from '../../../global/types'; import { ApiAttachment, + ApiBotInlineResult, + ApiBotInlineMediaResult, ApiSticker, ApiVideo, ApiNewPoll, @@ -16,7 +18,7 @@ import { ApiUser, MAIN_THREAD_ID, } from '../../../api/types'; -import { LangCode } from '../../../types'; +import { LangCode, InlineBotSettings } from '../../../types'; import { BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_ID, SCHEDULED_WHEN_ONLINE } from '../../../config'; import { IS_VOICE_RECORDING_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT, IS_IOS } from '../../../util/environment'; @@ -35,7 +37,6 @@ import { import { getAllowedAttachmentOptions, getChatSlowModeOptions, - isChatGroup, isChatPrivate, isChatAdmin, } from '../../../modules/helpers'; @@ -62,6 +63,8 @@ import useEmojiTooltip from './hooks/useEmojiTooltip'; import useMentionTooltip from './hooks/useMentionTooltip'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useLang from '../../../hooks/useLang'; +import useInlineBotTooltip from './hooks/useInlineBotTooltip'; +import windowSize from '../../../util/windowSize'; import DeleteMessageModal from '../../common/DeleteMessageModal.async'; import Button from '../../ui/Button'; @@ -69,6 +72,7 @@ import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton'; import Spinner from '../../ui/Spinner'; import AttachMenu from './AttachMenu.async'; import SymbolMenu from './SymbolMenu.async'; +import InlineBotTooltip from './InlineBotTooltip.async'; import MentionTooltip from './MentionTooltip.async'; import CustomSendMenu from './CustomSendMenu.async'; import StickerTooltip from './StickerTooltip.async'; @@ -105,7 +109,6 @@ type StateProps = { isRightColumnShown?: boolean; isSelectModeActive?: boolean; isForwarding?: boolean; - canSuggestMembers?: boolean; isPollModalOpen?: boolean; isPaymentModalOpen?: boolean; isReceiptModalOpen?: boolean; @@ -125,13 +128,16 @@ type StateProps = { baseEmojiKeywords?: Record; emojiKeywords?: Record; serverTimeOffset: number; + topInlineBotIds?: number[]; + isInlineBotLoading: boolean; + inlineBots?: Record; } & Pick; type DispatchProps = Pick; enum MainButtonState { @@ -169,7 +175,6 @@ const Composer: FC = ({ isRightColumnShown, isSelectModeActive, isForwarding, - canSuggestMembers, isPollModalOpen, isPaymentModalOpen, isReceiptModalOpen, @@ -177,6 +182,7 @@ const Composer: FC = ({ withScheduledButton, stickersForEmoji, groupChatMembers, + topInlineBotIds, currentUserId, usersById, lastSyncTime, @@ -187,6 +193,8 @@ const Composer: FC = ({ emojiKeywords, serverTimeOffset, recentEmojis, + inlineBots, + isInlineBotLoading, sendMessage, editMessage, saveDraft, @@ -203,7 +211,10 @@ const Composer: FC = ({ clearReceipt, addRecentEmoji, loadEmojiKeywords, + sendInlineBotResult, }) => { + const lang = useLang(); + // eslint-disable-next-line no-null/no-null const appendixRef = useRef(null); const [html, setHtml] = useState(''); @@ -213,7 +224,7 @@ const Composer: FC = ({ const [ scheduledMessageArgs, setScheduledMessageArgs, ] = useState(); - const lang = useLang(); + const { width: windowWidth } = windowSize.get(); // Cache for frequently updated state const htmlRef = useRef(html); @@ -282,17 +293,34 @@ const Composer: FC = ({ const { isMentionTooltipOpen, mentionFilter, closeMentionTooltip, insertMention, - mentionFilteredMembers, + mentionFilteredUsers, } = useMentionTooltip( - canSuggestMembers && !attachments.length, + !attachments.length, html, setHtml, undefined, groupChatMembers, + topInlineBotIds, currentUserId, usersById, ); + const { + isOpen: isInlineBotTooltipOpen, + id: inlineBotId, + isGallery: isInlineBotTooltipGallery, + switchPm: inlineBotSwitchPm, + results: inlineBotResults, + closeTooltip: closeInlineBotTooltip, + help: inlineBotHelp, + loadMore: loadMoreForInlineBot, + } = useInlineBotTooltip( + Boolean(!attachments.length && lastSyncTime), + chatId, + html, + inlineBots, + ); + const { isContextMenuOpen: isCustomSendMenuOpen, handleContextMenu, @@ -344,12 +372,10 @@ const Composer: FC = ({ setHtml(`${htmlRef.current!}${newHtml}`); - if (!IS_SINGLE_COLUMN_LAYOUT) { - // If selection is outside of input, set cursor at the end of input - requestAnimationFrame(() => { - focusEditableElement(messageInput); - }); - } + // If selection is outside of input, set cursor at the end of input + requestAnimationFrame(() => { + focusEditableElement(messageInput); + }); }, []); const removeSymbol = useCallback(() => { @@ -535,6 +561,25 @@ const Composer: FC = ({ } }, [shouldSchedule, openCalendar, sendMessage, resetComposer]); + const handleInlineBotSelect = useCallback((inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult) => { + if (connectionState !== 'connectionStateReady') { + return; + } + + sendInlineBotResult({ + id: inlineResult.id, + queryId: inlineResult.queryId, + }); + + const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; + if (IS_IOS && messageInput === document.activeElement) { + applyIosAutoCapitalizationFix(messageInput); + } + + clearDraft({ chatId, localOnly: true }); + requestAnimationFrame(resetComposer); + }, [chatId, clearDraft, connectionState, resetComposer, sendInlineBotResult]); + const handlePollSend = useCallback((poll: ApiNewPoll) => { if (shouldSchedule) { setScheduledMessageArgs({ poll }); @@ -712,7 +757,6 @@ const Composer: FC = ({ = ({ filter={mentionFilter} onClose={closeMentionTooltip} onInsertUserName={insertMention} - filteredChatMembers={mentionFilteredMembers} + filteredUsers={mentionFilteredUsers} usersById={usersById} />
@@ -792,15 +836,19 @@ const Composer: FC = ({ id="message-input-text" html={!attachments.length ? html : ''} placeholder={ - activeVoiceRecording && window.innerWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER ? '' : lang('Message') + activeVoiceRecording && windowWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER ? '' : lang('Message') } + forcedPlaceholder={inlineBotHelp} shouldSetFocus={isSymbolMenuOpen} shouldSuppressFocus={IS_SINGLE_COLUMN_LAYOUT && isSymbolMenuOpen} - shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen} + shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen || isInlineBotTooltipOpen} onUpdate={setHtml} onSend={onSend} onSuppressedFocus={closeSymbolMenu} /> + {isInlineBotLoading && Boolean(inlineBotId) && ( + + )} {withScheduledButton && (