Composer: Implement Inline Bots (#1206)

This commit is contained in:
Alexander Zinchuk 2021-07-16 17:44:24 +03:00
parent b44792afab
commit c3fd0d66e9
54 changed files with 1587 additions and 162 deletions

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ interface LocalDb {
documents: Record<string, GramJs.Document>;
stickerSets: Record<string, GramJs.StickerSet>;
photos: Record<string, GramJs.Photo>;
webDocuments: Record<string, GramJs.TypeWebDocument>;
}
export default {
@ -20,4 +21,5 @@ export default {
documents: {},
stickerSets: {},
photos: {},
webDocuments: {},
} as LocalDb;

View File

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

View File

@ -55,7 +55,7 @@ export {
} from './twoFaSettings';
export {
answerCallbackButton,
answerCallbackButton, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults, sendInlineBotResult,
} from './bots';
export {

View File

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

40
src/api/types/bots.ts Normal file
View File

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

View File

@ -5,4 +5,5 @@ export * from './updates';
export * from './media';
export * from './payments';
export * from './settings';
export * from './bots';
export * from './misc';

View File

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

View File

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

View File

@ -15,6 +15,7 @@ export interface ApiUser {
accessHash?: string;
avatarHash?: string;
photos?: ApiPhoto[];
botPlaceholder?: string;
canBeInvitedToGroup?: boolean;
// Obtained from GetFullUser / UserFullInfo

View File

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

View File

@ -20,11 +20,12 @@ type OwnProps = {
gif: ApiVideo;
observeIntersection: ObserveFn;
isDisabled?: boolean;
className?: string;
onClick: (gif: ApiVideo) => void;
};
const GifButton: FC<OwnProps> = ({
gif, observeIntersection, isDisabled, onClick,
gif, observeIntersection, isDisabled, className, onClick,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
@ -53,17 +54,18 @@ const GifButton: FC<OwnProps> = ({
[onClick, gif, videoData],
);
const className = buildClassName(
const fullClassName = buildClassName(
'GifButton',
gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal',
transitionClassNames,
localMediaHash,
className,
);
return (
<div
ref={ref}
className={className}
className={fullClassName}
onClick={handleClick}
>
{hasThumbnail && (

View File

@ -18,10 +18,9 @@
.Badge-wrapper {
display: flex;
margin-left: 1.5rem;
.Badge {
margin-left: 0.5rem;
margin-inline-start: .5rem;
}
}

View File

@ -53,7 +53,8 @@ type StateProps = {
};
type DispatchProps = Pick<GlobalActions, (
'loadAnimatedEmojis' | 'loadNotificationSettings' | 'loadNotificationExceptions' | 'updateIsOnline'
'loadAnimatedEmojis' | 'loadNotificationSettings' | 'loadNotificationExceptions' | 'updateIsOnline' |
'loadTopInlineBots'
)>;
const ANIMATION_DURATION = 350;
@ -81,6 +82,7 @@ const Main: FC<StateProps & DispatchProps> = ({
loadNotificationSettings,
loadNotificationExceptions,
updateIsOnline,
loadTopInlineBots,
}) => {
if (DEBUG && !DEBUG_isLogged) {
DEBUG_isLogged = true;
@ -95,8 +97,12 @@ const Main: FC<StateProps & DispatchProps> = ({
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));

View File

@ -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<OwnProps> = ({
attachments,
caption,
canSuggestMembers,
groupChatMembers,
currentUserId,
usersById,
@ -70,13 +68,14 @@ const AttachmentModal: FC<OwnProps> = ({
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<OwnProps> = ({
onClose={closeMentionTooltip}
filter={mentionFilter}
onInsertUserName={insertMention}
filteredChatMembers={mentionFilteredMembers}
filteredUsers={mentionFilteredUsers}
usersById={usersById}
/>
<EmojiTooltip

View File

@ -177,6 +177,11 @@
.message-input-wrapper {
display: flex;
> .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 {

View File

@ -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<string, string[]>;
emojiKeywords?: Record<string, string[]>;
serverTimeOffset: number;
topInlineBotIds?: number[];
isInlineBotLoading: boolean;
inlineBots?: Record<string, false | InlineBotSettings>;
} & Pick<GlobalState, 'connectionState'>;
type DispatchProps = Pick<GlobalActions, (
'sendMessage' | 'editMessage' | 'saveDraft' | 'forwardMessages' |
'clearDraft' | 'showDialog' | 'setStickerSearchQuery' | 'setGifSearchQuery' |
'openPollModal' | 'closePollModal' | 'loadScheduledHistory' | 'openChat' | 'closePaymentModal' |
'clearReceipt' | 'addRecentEmoji' | 'loadEmojiKeywords'
'clearReceipt' | 'addRecentEmoji' | 'loadEmojiKeywords' | 'sendInlineBotResult'
)>;
enum MainButtonState {
@ -169,7 +175,6 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
isRightColumnShown,
isSelectModeActive,
isForwarding,
canSuggestMembers,
isPollModalOpen,
isPaymentModalOpen,
isReceiptModalOpen,
@ -177,6 +182,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
withScheduledButton,
stickersForEmoji,
groupChatMembers,
topInlineBotIds,
currentUserId,
usersById,
lastSyncTime,
@ -187,6 +193,8 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
emojiKeywords,
serverTimeOffset,
recentEmojis,
inlineBots,
isInlineBotLoading,
sendMessage,
editMessage,
saveDraft,
@ -203,7 +211,10 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
clearReceipt,
addRecentEmoji,
loadEmojiKeywords,
sendInlineBotResult,
}) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const appendixRef = useRef<HTMLDivElement>(null);
const [html, setHtml] = useState<string>('');
@ -213,7 +224,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
const [
scheduledMessageArgs, setScheduledMessageArgs,
] = useState<GlobalState['messages']['contentToBeScheduled'] | undefined>();
const lang = useLang();
const { width: windowWidth } = windowSize.get();
// Cache for frequently updated state
const htmlRef = useRef<string>(html);
@ -282,17 +293,34 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
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<OwnProps & StateProps & DispatchProps> = ({
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<OwnProps & StateProps & DispatchProps> = ({
}
}, [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<OwnProps & StateProps & DispatchProps> = ({
<AttachmentModal
attachments={attachments}
caption={attachments.length ? html : ''}
canSuggestMembers={canSuggestMembers}
groupChatMembers={groupChatMembers}
currentUserId={currentUserId}
usersById={usersById}
@ -751,7 +795,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
filter={mentionFilter}
onClose={closeMentionTooltip}
onInsertUserName={insertMention}
filteredChatMembers={mentionFilteredMembers}
filteredUsers={mentionFilteredUsers}
usersById={usersById}
/>
<div id="message-compose">
@ -792,15 +836,19 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
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) && (
<Spinner color="gray" />
)}
{withScheduledButton && (
<Button
round
@ -867,6 +915,17 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
onClose={closeBotKeyboard}
/>
)}
<InlineBotTooltip
isOpen={isInlineBotTooltipOpen}
botId={inlineBotId}
allowedAttachmentOptions={allowedAttachmentOptions}
isGallery={isInlineBotTooltipGallery}
inlineBotResults={inlineBotResults}
switchPm={inlineBotSwitchPm}
onSelectResult={handleInlineBotSelect}
loadMore={loadMoreForInlineBot}
onClose={closeInlineBotTooltip}
/>
<SymbolMenu
isOpen={isSymbolMenuOpen}
allowedAttachmentOptions={allowedAttachmentOptions}
@ -965,10 +1024,10 @@ export default memo(withGlobal<OwnProps>(
shouldSchedule: messageListType === 'scheduled',
botKeyboardMessageId: messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined,
isForwarding: chatId === global.forwardMessages.toChatId,
canSuggestMembers: chat && isChatGroup(chat),
isPollModalOpen: global.isPollModalOpen,
stickersForEmoji: global.stickers.forEmoji.stickers,
groupChatMembers: chat && chat.fullInfo && chat.fullInfo.members,
topInlineBotIds: global.topInlineBots && global.topInlineBots.userIds,
currentUserId: global.currentUserId,
usersById: global.users.byId,
lastSyncTime: global.lastSyncTime,
@ -981,6 +1040,8 @@ export default memo(withGlobal<OwnProps>(
baseEmojiKeywords: baseEmojiKeywords ? baseEmojiKeywords.keywords : undefined,
emojiKeywords: emojiKeywords ? emojiKeywords.keywords : undefined,
serverTimeOffset: global.serverTimeOffset,
inlineBots: global.inlineBots.byUsername,
isInlineBotLoading: global.inlineBots.isLoading,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
@ -1000,5 +1061,6 @@ export default memo(withGlobal<OwnProps>(
'openChat',
'addRecentEmoji',
'loadEmojiKeywords',
'sendInlineBotResult',
]),
)(Composer));

View File

@ -0,0 +1,15 @@
import React, { FC, memo } from '../../../lib/teact/teact';
import { OwnProps } from './InlineBotTooltip';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const InlineBotTooltipAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const InlineBotTooltip = useModuleLoader(Bundles.Extra, 'InlineBotTooltip', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return InlineBotTooltip ? <InlineBotTooltip {...props} /> : undefined;
};
export default memo(InlineBotTooltipAsync);

View File

@ -0,0 +1,54 @@
.InlineBotTooltip {
.switch-pm .title {
margin: 0 auto;
font-weight: 500;
}
--border-radius-default: 0;
&.gallery {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 1px;
padding: 0;
@media (max-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
.switch-pm {
grid-column: 1 / -1;
.ListItem-button {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.GifButton {
grid-column-end: initial;
}
.StickerButton {
width: initial;
height: 0;
margin: 0;
padding-bottom: 100%;
border-radius: 0;
.AnimatedSticker, img, canvas {
position: absolute;
top: 0;
left: 0;
width: 100% !important;
height: 100% !important;
@media (min-width: 600px) {
top: .25rem;
left: .25rem;
width: calc(100% - .5rem) !important;
height: calc(100% - .5rem) !important;
}
}
}
}
}

View File

@ -0,0 +1,225 @@
import React, {
FC, memo, useCallback, useEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types';
import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm } from '../../../api/types';
import { IAllowedAttachmentOptions } from '../../../modules/helpers';
import { LoadMoreDirection } from '../../../types';
import { IS_TOUCH_ENV } from '../../../util/environment';
import setTooltipItemVisible from '../../../util/setTooltipItemVisible';
import buildClassName from '../../../util/buildClassName';
import captureKeyboardListeners from '../../../util/captureKeyboardListeners';
import cycleRestrict from '../../../util/cycleRestrict';
import useShowTransition from '../../../hooks/useShowTransition';
import { throttle } from '../../../util/schedulers';
import { pick } from '../../../util/iteratees';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import MediaResult from './inlineResults/MediaResult';
import ArticleResult from './inlineResults/ArticleResult';
import GifResult from './inlineResults/GifResult';
import StickerResult from './inlineResults/StickerResult';
import ListItem from '../../ui/ListItem';
import InfiniteScroll from '../../ui/InfiniteScroll';
import './InlineBotTooltip.scss';
const INTERSECTION_DEBOUNCE_MS = 200;
const runThrottled = throttle((cb) => cb(), 500, true);
export type OwnProps = {
isOpen: boolean;
botId?: number;
isGallery?: boolean;
allowedAttachmentOptions: IAllowedAttachmentOptions;
inlineBotResults?: (ApiBotInlineResult | ApiBotInlineMediaResult)[];
switchPm?: ApiBotInlineSwitchPm;
onSelectResult: (inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult) => void;
loadMore: NoneToVoidFunction;
onClose: NoneToVoidFunction;
};
type DispatchProps = Pick<GlobalActions, ('sendBotCommand' | 'openChat' | 'sendInlineBotResult')>;
const InlineBotTooltip: FC<OwnProps & DispatchProps> = ({
isOpen,
botId,
isGallery,
inlineBotResults,
switchPm,
loadMore,
onClose,
openChat,
sendBotCommand,
onSelectResult,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const {
observe: observeIntersection,
} = useIntersectionObserver({
rootRef: containerRef,
debounceMs: INTERSECTION_DEBOUNCE_MS,
isDisabled: !isOpen,
});
useEffect(() => {
setSelectedIndex(isGallery ? -1 : 0);
}, [inlineBotResults, isGallery]);
useEffect(() => {
setTooltipItemVisible('.chat-item-clickable', selectedIndex, containerRef);
}, [selectedIndex]);
const getSelectedIndex = useCallback((newIndex: number) => {
if (!inlineBotResults || !inlineBotResults.length) {
return -1;
}
return cycleRestrict(inlineBotResults.length, newIndex);
}, [inlineBotResults]);
const handleArrowKey = useCallback((value: number, e: KeyboardEvent) => {
if (isGallery) {
return;
}
e.preventDefault();
setSelectedIndex((index) => (getSelectedIndex(index + value)));
}, [isGallery, getSelectedIndex]);
const handleSelectInlineBotResult = useCallback((e: KeyboardEvent) => {
if (inlineBotResults && inlineBotResults.length && selectedIndex > -1) {
const inlineResult = inlineBotResults[selectedIndex];
if (inlineResult) {
e.preventDefault();
onSelectResult(inlineResult);
}
}
}, [inlineBotResults, onSelectResult, selectedIndex]);
const handleLoadMore = useCallback(({ direction }: { direction: LoadMoreDirection }) => {
if (direction === LoadMoreDirection.Backwards) {
runThrottled(loadMore);
}
}, [loadMore]);
useEffect(() => (isOpen ? captureKeyboardListeners({
onEsc: onClose,
onUp: (e: KeyboardEvent) => handleArrowKey(-1, e),
onDown: (e: KeyboardEvent) => handleArrowKey(1, e),
onEnter: handleSelectInlineBotResult,
}) : undefined), [handleArrowKey, handleSelectInlineBotResult, isGallery, isOpen, onClose]);
const handleSendPm = useCallback(() => {
openChat({ id: botId });
sendBotCommand({ chatId: botId, command: `/start ${switchPm!.startParam}` });
}, [botId, openChat, sendBotCommand, switchPm]);
if (!shouldRender || !inlineBotResults || (!inlineBotResults.length && !switchPm)) {
return undefined;
}
const className = buildClassName(
'InlineBotTooltip composer-tooltip',
IS_TOUCH_ENV ? 'no-scrollbar' : 'custom-scroll',
isGallery && 'gallery',
transitionClassNames,
);
function renderSwitchPm() {
return (
<ListItem ripple className="switch-pm scroll-item" onClick={handleSendPm}>
<span className="title">{switchPm!.text}</span>
</ListItem>
);
}
function renderContent() {
return inlineBotResults!.map((inlineBotResult, index) => {
switch (inlineBotResult.type) {
case 'gif':
return (
<GifResult
key={inlineBotResult.id}
inlineResult={inlineBotResult}
observeIntersection={observeIntersection}
onClick={onSelectResult}
/>
);
case 'photo':
return (
<MediaResult
key={inlineBotResult.id}
isForGallery={isGallery}
inlineResult={inlineBotResult}
onClick={onSelectResult}
/>
);
case 'sticker':
return (
<StickerResult
key={inlineBotResult.id}
inlineResult={inlineBotResult}
observeIntersection={observeIntersection}
onClick={onSelectResult}
/>
);
case 'video':
case 'game':
return (
<MediaResult
key={inlineBotResult.id}
focus={selectedIndex === index}
inlineResult={inlineBotResult}
onClick={onSelectResult}
/>
);
case 'article':
case 'audio':
return (
<ArticleResult
key={inlineBotResult.id}
focus={selectedIndex === index}
inlineResult={inlineBotResult}
onClick={onSelectResult}
/>
);
default:
return undefined;
}
});
}
return (
<InfiniteScroll
ref={containerRef}
className={className}
items={inlineBotResults}
itemSelector=".chat-item-clickable"
noFastList
onLoadMore={handleLoadMore}
sensitiveArea={160}
>
{switchPm && renderSwitchPm()}
{renderContent()}
</InfiniteScroll>
);
};
export default memo(withGlobal<OwnProps>(
undefined,
(setGlobal, actions): DispatchProps => pick(actions, [
'sendBotCommand', 'openChat', 'sendInlineBotResult',
]),
)(InlineBotTooltip));

View File

@ -23,7 +23,6 @@
.title {
margin-inline-end: .625rem;
max-width: 70%;
flex: 1 0 auto;
}
.handle {

View File

@ -3,14 +3,12 @@ import React, {
} from '../../../lib/teact/teact';
import usePrevious from '../../../hooks/usePrevious';
import { ApiChatMember, ApiUser } from '../../../api/types';
import { ApiUser } from '../../../api/types';
import useShowTransition from '../../../hooks/useShowTransition';
import buildClassName from '../../../util/buildClassName';
import captureKeyboardListeners from '../../../util/captureKeyboardListeners';
import findInViewport from '../../../util/findInViewport';
import isFullyVisible from '../../../util/isFullyVisible';
import fastSmoothScroll from '../../../util/fastSmoothScroll';
import setTooltipItemVisible from '../../../util/setTooltipItemVisible';
import cycleRestrict from '../../../util/cycleRestrict';
import ListItem from '../../ui/ListItem';
@ -18,38 +16,12 @@ import PrivateChatInfo from '../../common/PrivateChatInfo';
import './MentionTooltip.scss';
const VIEWPORT_MARGIN = 8;
const SCROLL_MARGIN = 10;
function setItemVisible(index: number, containerRef: Record<string, any>) {
const container = containerRef.current!;
if (!container || index < 0) {
return;
}
const { visibleIndexes, allElements } = findInViewport(
container,
'.chat-item-clickable',
VIEWPORT_MARGIN,
true,
true,
);
if (!allElements.length || !allElements[index]) {
return;
}
const first = visibleIndexes[0];
if (!visibleIndexes.includes(index)
|| (index === first && !isFullyVisible(container, allElements[first]))) {
const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end';
fastSmoothScroll(container, allElements[index], position, SCROLL_MARGIN);
}
}
export type OwnProps = {
isOpen: boolean;
filter: string;
onClose: () => void;
onInsertUserName: (user: ApiUser, forceFocus?: boolean) => void;
filteredChatMembers?: ApiChatMember[];
filteredUsers?: ApiUser[];
usersById?: Record<number, ApiUser>;
};
@ -59,19 +31,19 @@ const MentionTooltip: FC<OwnProps> = ({
onClose,
onInsertUserName,
usersById,
filteredChatMembers,
filteredUsers,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false);
const getSelectedIndex = useCallback((newIndex: number) => {
if (!filteredChatMembers) {
if (!filteredUsers) {
return -1;
}
const membersCount = filteredChatMembers!.length;
const membersCount = filteredUsers!.length;
return cycleRestrict(membersCount, newIndex);
}, [filteredChatMembers]);
}, [filteredUsers]);
const [selectedMentionIndex, setSelectedMentionIndex] = useState(-1);
@ -90,14 +62,14 @@ const MentionTooltip: FC<OwnProps> = ({
}, [usersById, onInsertUserName]);
const handleSelectMention = useCallback((e: KeyboardEvent) => {
if (filteredChatMembers && filteredChatMembers.length && selectedMentionIndex > -1) {
const member = filteredChatMembers[selectedMentionIndex];
if (filteredUsers && filteredUsers.length && selectedMentionIndex > -1) {
const member = filteredUsers[selectedMentionIndex];
if (member) {
e.preventDefault();
handleUserSelect(member.userId, true);
handleUserSelect(member.id, true);
}
}
}, [filteredChatMembers, selectedMentionIndex, handleUserSelect]);
}, [filteredUsers, selectedMentionIndex, handleUserSelect]);
useEffect(() => (isOpen ? captureKeyboardListeners({
onEsc: onClose,
@ -108,28 +80,28 @@ const MentionTooltip: FC<OwnProps> = ({
}) : undefined), [isOpen, onClose, handleArrowKey, handleSelectMention]);
useEffect(() => {
if (filteredChatMembers && !filteredChatMembers.length) {
if (filteredUsers && !filteredUsers.length) {
onClose();
}
}, [filteredChatMembers, onClose]);
}, [filteredUsers, onClose]);
useEffect(() => {
setSelectedMentionIndex(0);
}, [filter]);
useEffect(() => {
setItemVisible(selectedMentionIndex, containerRef);
setTooltipItemVisible('.chat-item-clickable', selectedMentionIndex, containerRef);
}, [selectedMentionIndex]);
const prevChatMembers = usePrevious(
filteredChatMembers && filteredChatMembers.length
? filteredChatMembers
filteredUsers && filteredUsers.length
? filteredUsers
: undefined,
shouldRender,
);
const renderedChatMembers = filteredChatMembers && !filteredChatMembers.length
const renderedChatMembers = filteredUsers && !filteredUsers.length
? prevChatMembers
: filteredChatMembers;
: filteredUsers;
if (!shouldRender || (renderedChatMembers && !renderedChatMembers.length)) {
return undefined;
@ -142,15 +114,15 @@ const MentionTooltip: FC<OwnProps> = ({
return (
<div className={className} ref={containerRef}>
{renderedChatMembers && renderedChatMembers.map(({ userId }, index) => (
{renderedChatMembers && renderedChatMembers.map(({ id }, index) => (
<ListItem
key={userId}
key={id}
className="chat-item-clickable scroll-item"
onClick={() => handleUserSelect(userId)}
onClick={() => handleUserSelect(id)}
focus={selectedMentionIndex === index}
>
<PrivateChatInfo
userId={userId}
userId={id}
avatarSize="small"
withUsername
/>

View File

@ -22,6 +22,7 @@ import useFlag from '../../../hooks/useFlag';
import parseEmojiOnlyString from '../../common/helpers/parseEmojiOnlyString';
import { isSelectionInsideInput } from './helpers/selection';
import useLang from '../../../hooks/useLang';
import renderText from '../../common/helpers/renderText';
import TextFormatter from './TextFormatter';
@ -36,6 +37,7 @@ type OwnProps = {
editableInputId?: string;
html: string;
placeholder: string;
forcedPlaceholder?: string;
shouldSetFocus: boolean;
shouldSuppressFocus?: boolean;
shouldSuppressTextFormatter?: boolean;
@ -78,6 +80,7 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
editableInputId,
html,
placeholder,
forcedPlaceholder,
shouldSetFocus,
shouldSuppressFocus,
shouldSuppressTextFormatter,
@ -322,10 +325,6 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
}
useEffect(() => {
if (IS_TOUCH_ENV) {
return;
}
focusInput();
}, [currentChatId, focusInput, replyingToId, shouldSetFocus]);
@ -382,13 +381,14 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
onTouchCancel={handleTouchSelection}
/>
<div ref={cloneRef} className={buildClassName(className, 'clone')} dir="auto" />
<span className="placeholder-text" dir="auto">{placeholder}</span>
{!forcedPlaceholder && <span className="placeholder-text" dir="auto">{placeholder}</span>}
<TextFormatter
isOpen={isTextFormatterOpen}
anchorPosition={textFormatterAnchorPosition}
selectedRange={selectedRange}
onClose={handleCloseTextFormatter}
/>
{forcedPlaceholder && <span className="forced-placeholder">{renderText(forcedPlaceholder!)}</span>}
</div>
);
};

View File

@ -3,7 +3,6 @@ import {
} from '../../../../lib/teact/teact';
import { EDITABLE_INPUT_ID } from '../../../../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../../util/environment';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import {
EmojiData, EmojiModule, EmojiRawData, uncompressEmoji,
@ -163,11 +162,9 @@ export default function useEmojiTooltip(
if (atIndex !== -1) {
onUpdateHtml(`${html.substr(0, atIndex)}${textEmoji}`);
const messageInput = document.getElementById(inputId)!;
if (!IS_SINGLE_COLUMN_LAYOUT) {
requestAnimationFrame(() => {
focusEditableElement(messageInput, true);
});
}
requestAnimationFrame(() => {
focusEditableElement(messageInput, true);
});
}
unmarkIsOpen();

View File

@ -0,0 +1,88 @@
import { useCallback, useEffect, useState } from '../../../../lib/teact/teact';
import { getDispatch } from '../../../../lib/teact/teactn';
import { InlineBotSettings } from '../../../../types';
import useFlag from '../../../../hooks/useFlag';
import usePrevious from '../../../../hooks/usePrevious';
const tempEl = document.createElement('div');
const INLINE_BOT_QUERY_REGEXP = /^@([a-z0-9_]{1,32})[\u00A0\u0020]+(.*)/i;
const HAS_NEW_LINE = /^@([a-z0-9_]{1,32})[\u00A0\u0020]+\n{2,}/i;
export default function useInlineBotTooltip(
isAllowed: boolean,
chatId: number,
html: string,
inlineBots?: Record<string, false | InlineBotSettings>,
) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [botSettings, setBotSettings] = useState<undefined | false | InlineBotSettings>();
const text = getPlainText(html);
const { queryInlineBot, resetInlineBot } = getDispatch();
const { username, query, canShowHelp } = parseStartWithUsernameString(text);
const usernameLowered = username.toLowerCase();
const prevUsername = usePrevious(username);
useEffect(() => {
if (isAllowed && usernameLowered && chatId) {
queryInlineBot({ chatId, username: usernameLowered, query });
}
}, [query, isAllowed, queryInlineBot, chatId, usernameLowered]);
const loadMore = useCallback(() => {
queryInlineBot({
chatId, username: usernameLowered, query, offset: botSettings && botSettings.offset,
});
}, [botSettings, chatId, query, queryInlineBot, usernameLowered]);
const inlineBotData = inlineBots && inlineBots[usernameLowered];
useEffect(() => {
setBotSettings(inlineBotData);
}, [inlineBotData]);
useEffect(() => {
if (
isAllowed && botSettings && botSettings.id
&& (botSettings.switchPm || (botSettings.results && botSettings.results.length))
) {
markIsOpen();
} else {
unmarkIsOpen();
}
}, [botSettings, isAllowed, markIsOpen, unmarkIsOpen]);
if (prevUsername !== username) {
resetInlineBot({ username: prevUsername });
}
return {
isOpen,
closeTooltip: unmarkIsOpen,
loadMore,
username,
id: botSettings ? botSettings.id : undefined,
isGallery: botSettings ? botSettings.isGallery : undefined,
switchPm: botSettings ? botSettings.switchPm : undefined,
results: botSettings ? botSettings.results : undefined,
help: canShowHelp && botSettings && botSettings.help ? `@${username} ${botSettings.help}` : undefined,
};
}
function getPlainText(html: string) {
tempEl.innerHTML = html.replace(/<br>/g, '\n');
return tempEl.innerText;
}
function parseStartWithUsernameString(text: string) {
const result = text.match(INLINE_BOT_QUERY_REGEXP);
if (!result) {
return { username: '', query: '', canShowHelp: false };
}
return {
username: result[1],
query: result[2],
canShowHelp: result[2] === '' && !text.match(HAS_NEW_LINE),
};
}

View File

@ -1,14 +1,19 @@
import { useCallback, useEffect, useState } from '../../../../lib/teact/teact';
import {
useCallback, useEffect, useState, useMemo,
} from '../../../../lib/teact/teact';
import { ApiMessageEntityTypes, ApiChatMember, ApiUser } from '../../../../api/types';
import { EDITABLE_INPUT_ID } from '../../../../config';
import { getUserFirstOrLastName } from '../../../../modules/helpers';
import searchUserName from '../helpers/searchUserName';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../../util/environment';
import focusEditableElement from '../../../../util/focusEditableElement';
import useFlag from '../../../../hooks/useFlag';
import { unique } from '../../../../util/iteratees';
import { throttle } from '../../../../util/schedulers';
const tempEl = document.createElement('div');
const RE_NOT_USERNAME_SEARCH = /[^@_\d\wа-яё]+/i;
const runThrottled = throttle((cb) => cb(), 500, true);
export default function useMentionTooltip(
canSuggestMembers: boolean | undefined,
@ -16,27 +21,42 @@ export default function useMentionTooltip(
onUpdateHtml: (html: string) => void,
inputId: string = EDITABLE_INPUT_ID,
groupChatMembers?: ApiChatMember[],
topInlineBotIds?: number[],
currentUserId?: number,
usersById?: Record<number, ApiUser>,
) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [currentFilter, setCurrentFilter] = useState('');
const [filteredMembers, setFilteredMembers] = useState<ApiChatMember[]>([]);
const [usersToMention, setUsersToMention] = useState<ApiUser[] | undefined>();
const getFilteredMembers = useCallback((filter) => {
if (!groupChatMembers || !usersById) {
return undefined;
const topInlineBots = useMemo(() => {
return (topInlineBotIds || []).map((id) => usersById && usersById[id]).filter<ApiUser>(Boolean as any);
}, [topInlineBotIds, usersById]);
const getFilteredUsers = useCallback((filter, withInlineBots: boolean) => {
if (!(groupChatMembers || topInlineBotIds) || !usersById) {
setUsersToMention(undefined);
return;
}
runThrottled(() => {
const inlineBots = (withInlineBots ? topInlineBots : []).filter((inlineBot) => {
return !filter || searchUserName(filter, inlineBot);
});
return groupChatMembers.filter(({ userId }) => {
const user = usersById[userId];
if (userId === currentUserId || !user) {
return false;
}
const chatMembers = (groupChatMembers || [])
.map(({ userId }) => usersById[userId])
.filter((user) => {
if (!user || user.id === currentUserId) {
return false;
}
return !filter || searchUserName(filter, user);
return !filter || searchUserName(filter, user);
});
setUsersToMention(unique(inlineBots.concat(chatMembers)));
});
}, [groupChatMembers, currentUserId, usersById]);
}, [currentUserId, groupChatMembers, topInlineBotIds, topInlineBots, usersById]);
useEffect(() => {
if (!canSuggestMembers || !html.length) {
@ -44,22 +64,24 @@ export default function useMentionTooltip(
return;
}
const usernameFilter = getUsernameFilter(html);
const usernameFilter = html.includes('@') && getUsernameFilter(html);
if (usernameFilter) {
const filter = usernameFilter ? usernameFilter.substr(1) : '';
const membersToMention = getFilteredMembers(filter);
if (membersToMention && membersToMention.length) {
markIsOpen();
setCurrentFilter(filter);
setFilteredMembers(membersToMention);
} else {
unmarkIsOpen();
}
setCurrentFilter(filter);
getFilteredUsers(filter, canSuggestInlineBots(html));
} else {
unmarkIsOpen();
}
}, [canSuggestMembers, html, getFilteredMembers, markIsOpen, unmarkIsOpen]);
}, [canSuggestMembers, html, getFilteredUsers, markIsOpen, unmarkIsOpen]);
useEffect(() => {
if (usersToMention && usersToMention.length) {
markIsOpen();
} else {
unmarkIsOpen();
}
}, [markIsOpen, unmarkIsOpen, usersToMention]);
const insertMention = useCallback((user: ApiUser, forceFocus = false) => {
if (!user.username && !getUserFirstOrLastName(user)) {
@ -80,11 +102,9 @@ export default function useMentionTooltip(
if (atIndex !== -1) {
onUpdateHtml(`${html.substr(0, atIndex)}${insertedHtml}&nbsp;`);
const messageInput = document.getElementById(inputId)!;
if (!IS_SINGLE_COLUMN_LAYOUT) {
requestAnimationFrame(() => {
focusEditableElement(messageInput, forceFocus);
});
}
requestAnimationFrame(() => {
focusEditableElement(messageInput, forceFocus);
});
}
unmarkIsOpen();
@ -95,12 +115,11 @@ export default function useMentionTooltip(
mentionFilter: currentFilter,
closeMentionTooltip: unmarkIsOpen,
insertMention,
mentionFilteredMembers: filteredMembers,
mentionFilteredUsers: usersToMention,
};
}
function getUsernameFilter(html: string) {
const tempEl = document.createElement('div');
tempEl.innerHTML = html;
const text = tempEl.innerText.replace(/\n$/i, '');
@ -116,3 +135,10 @@ function getUsernameFilter(html: string) {
return lastWord;
}
function canSuggestInlineBots(html: string) {
tempEl.innerHTML = html;
const text = tempEl.innerText;
return text.startsWith('@');
}

View File

@ -0,0 +1,33 @@
import React, { FC, memo, useCallback } from '../../../../lib/teact/teact';
import { ApiBotInlineResult } from '../../../../api/types';
import BaseResult from './BaseResult';
export type OwnProps = {
focus?: boolean;
inlineResult: ApiBotInlineResult;
onClick: (result: ApiBotInlineResult) => void;
};
const ArticleResult: FC<OwnProps> = ({ focus, inlineResult, onClick }) => {
const {
title, url, description, webThumbnail,
} = inlineResult;
const handleClick = useCallback(() => {
onClick(inlineResult);
}, [inlineResult, onClick]);
return (
<BaseResult
focus={focus}
thumbnail={webThumbnail}
title={title || url}
description={description}
onClick={handleClick}
/>
);
};
export default memo(ArticleResult);

View File

@ -0,0 +1,62 @@
.BaseResult {
&.chat-item-clickable > .ListItem-button {
padding-left: 1.25rem !important;
padding-right: 1.25rem !important;
.title {
display: block;
text-overflow: ellipsis;
}
}
.thumb {
background-color: var(--color-background-secondary);
flex: 0 0 3rem;
width: 3rem;
height: 3rem;
line-height: 3rem;
border-radius: var(--border-radius-default-tiny);
display: inline-flex;
align-content: center;
justify-content: center;
margin-inline-end: .5rem;
overflow: hidden;
font-size: 1.5rem;
img:not(.emoji) {
width: 100%;
object-fit: cover;
}
img.emoji {
width: 1.5rem;
height: 1.5rem;
margin: .75rem 0 0;
}
}
.content-inner {
min-width: 0;
}
.title {
font-weight: 500;
text-align: left;
unicode-bidi: plaintext;
}
.description {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
unicode-bidi: plaintext;
}
&[dir=rtl] .title,
&[dir=rtl] .description {
text-align: right;
}
}

View File

@ -0,0 +1,62 @@
import React, { FC, memo } from '../../../../lib/teact/teact';
import { ApiWebDocument } from '../../../../api/types';
import { getFirstLetters } from '../../../../util/textFormat';
import renderText from '../../../common/helpers/renderText';
import useMedia from '../../../../hooks/useMedia';
import ListItem from '../../../ui/ListItem';
import './BaseResult.scss';
export type OwnProps = {
focus?: boolean;
thumbnail?: ApiWebDocument;
thumbUrl?: string;
title?: string;
description?: string;
transitionClassNames?: string;
onClick: NoneToVoidFunction;
};
const BaseResult: FC<OwnProps> = ({
title,
description,
thumbnail,
thumbUrl,
focus,
transitionClassNames = '',
onClick,
}) => {
let content: string | undefined = '';
const thumbnailDataUrl = useMedia(thumbnail ? `webDocument:${thumbnail.url}` : undefined);
thumbUrl = thumbUrl || thumbnailDataUrl;
if (thumbUrl) {
content = (
<img src={thumbUrl} className={transitionClassNames} alt="" decoding="async" draggable="false" />
);
} else if (title) {
content = getFirstLetters(title, 1);
}
return (
<ListItem
focus={focus}
className="BaseResult chat-item-clickable"
onClick={onClick}
>
<span className="thumb">
{typeof content === 'string' ? renderText(content) : content}
</span>
<div className="content-inner">
{title && (<div className="title">{title}</div>)}
{description && (<div className="description">{description}</div>)}
</div>
</ListItem>
);
};
export default memo(BaseResult);

View File

@ -0,0 +1,40 @@
import React, {
FC, memo, useCallback,
} from '../../../../lib/teact/teact';
import { ApiBotInlineMediaResult, ApiBotInlineResult } from '../../../../api/types';
import { ObserveFn } from '../../../../hooks/useIntersectionObserver';
import GifButton from '../../../common/GifButton';
type OwnProps = {
inlineResult: ApiBotInlineMediaResult;
observeIntersection: ObserveFn;
onClick: (result: ApiBotInlineResult) => void;
};
const GifResult: FC<OwnProps> = ({
inlineResult, observeIntersection, onClick,
}) => {
const { gif } = inlineResult;
const handleClick = useCallback(() => {
onClick(inlineResult);
}, [inlineResult, onClick]);
if (!gif) {
return undefined;
}
return (
<GifButton
gif={gif}
observeIntersection={observeIntersection}
className="chat-item-clickable"
onClick={handleClick}
/>
);
};
export default memo(GifResult);

View File

@ -0,0 +1,16 @@
.MediaResult {
height: 0;
padding-bottom: 100%;
overflow: hidden;
position: relative;
cursor: pointer;
img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}

View File

@ -0,0 +1,76 @@
import React, { FC, memo, useCallback } from '../../../../lib/teact/teact';
import {
ApiBotInlineMediaResult, ApiBotInlineResult, ApiPhoto, ApiThumbnail, ApiWebDocument,
} from '../../../../api/types';
import useMedia from '../../../../hooks/useMedia';
import useTransitionForMedia from '../../../../hooks/useTransitionForMedia';
import BaseResult from './BaseResult';
import './MediaResult.scss';
export type OwnProps = {
focus?: boolean;
isForGallery?: boolean;
inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult;
onClick: (result: ApiBotInlineResult) => void;
};
const MediaResult: FC<OwnProps> = ({
focus, isForGallery, inlineResult, onClick,
}) => {
let photo: ApiPhoto | undefined;
let thumbnail: ApiThumbnail | undefined;
let webThumbnail: ApiWebDocument | undefined;
if ('photo' in inlineResult) {
photo = inlineResult.photo;
}
// For results with type=video (for example @stikstokbot)
if ('thumbnail' in inlineResult) {
thumbnail = inlineResult.thumbnail;
}
if ('webThumbnail' in inlineResult && isForGallery) {
webThumbnail = inlineResult.webThumbnail;
}
const thumbnailDataUrl = useMedia(webThumbnail ? `webDocument:${webThumbnail.url}` : undefined);
const mediaBlobUrl = useMedia(photo && `photo${photo.id}?size=m`);
const {
shouldRenderThumb, shouldRenderFullMedia, transitionClassNames,
} = useTransitionForMedia(mediaBlobUrl, 'slow');
const handleClick = useCallback(() => {
onClick(inlineResult);
}, [inlineResult, onClick]);
if (isForGallery) {
return (
<div className="MediaResult chat-item-clickable" onClick={handleClick}>
{shouldRenderThumb && (
<img src={(photo && photo.thumbnail && photo.thumbnail.dataUri) || thumbnailDataUrl} alt="" />
)}
{shouldRenderFullMedia && (
<img src={mediaBlobUrl} className={`${transitionClassNames} full-media`} alt="" />
)}
</div>
);
}
const { title, description } = inlineResult;
return (
<BaseResult
focus={focus}
thumbUrl={shouldRenderFullMedia ? mediaBlobUrl : (thumbnail && thumbnail.dataUri) || thumbnailDataUrl}
transitionClassNames={shouldRenderFullMedia ? transitionClassNames : undefined}
title={title}
description={description}
onClick={handleClick}
/>
);
};
export default memo(MediaResult);

View File

@ -0,0 +1,22 @@
.StickerResult {
height: 0;
padding-bottom: 100%;
overflow: hidden;
background: transparent no-repeat center;
background-size: contain;
cursor: pointer;
transition: background-color .15s ease, opacity .3s ease !important;
position: relative;
.AnimatedSticker, img, canvas {
position: absolute;
top: 0;
left: 0;
width: 100% !important;
height: 100% !important;
}
img {
object-fit: contain;
}
}

View File

@ -0,0 +1,36 @@
import React, { FC, memo } from '../../../../lib/teact/teact';
import { ApiBotInlineMediaResult, ApiBotInlineResult } from '../../../../api/types';
import { STICKER_SIZE_INLINE_BOT_RESULT } from '../../../../config';
import { ObserveFn } from '../../../../hooks/useIntersectionObserver';
import StickerButton from '../../../common/StickerButton';
type OwnProps = {
inlineResult: ApiBotInlineMediaResult;
observeIntersection: ObserveFn;
onClick: (result: ApiBotInlineResult) => void;
};
const StickerResult: FC<OwnProps> = ({ inlineResult, observeIntersection, onClick }) => {
const { sticker } = inlineResult;
if (!sticker) {
return undefined;
}
return (
<StickerButton
sticker={sticker}
size={STICKER_SIZE_INLINE_BOT_RESULT}
observeIntersection={observeIntersection}
title={sticker.emoji}
className="chat-item-clickable"
onClick={onClick}
clickArg={inlineResult}
/>
);
};
export default memo(StickerResult);

View File

@ -706,7 +706,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
}
function renderSenderName() {
const shouldRender = !customShape && (
const shouldRender = !(customShape && !viaBotId) && (
(withSenderName && !photo && !video) || asForwarded || viaBotId || forceSenderName
) && (!isInDocumentGroup || isFirstInDocumentGroup);
@ -716,7 +716,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
let senderTitle;
let senderColor;
if (senderPeer) {
if (senderPeer && !(customShape && viaBotId)) {
senderTitle = getSenderTitle(lang, senderPeer);
if (!asForwarded) {

View File

@ -69,7 +69,6 @@
}
& > .MessageMeta {
position: relative;
top: auto;
bottom: -.5rem !important;
margin-top: -.25rem;
@ -164,10 +163,14 @@
}
.via {
padding: 0 0.2rem;
padding-right: .25rem;
font-weight: 400;
}
span + .via {
padding-left: .25rem;
}
.admin-title {
flex: 1;
margin-left: 1rem;
@ -311,6 +314,30 @@
--border-top-left-radius: 0;
--border-top-right-radius: 0;
}
&.custom-shape.is-via-bot {
font-size: inherit !important;
.message-title {
display: inline-flex;
position: relative;
top: .125rem;
max-width: calc(100% - 3rem);
margin-left: calc(100% - 3rem);
padding: 0 .5rem;
background-color: var(--background-color);
border-radius: var(--border-radius-messages);
.Message.own & {
margin-left: -3rem;
}
}
.media-inner {
margin-top: 0.375rem;
}
}
}
.message-content.media, .WebPage {

View File

@ -119,7 +119,6 @@ const GifSearch: FC<OwnProps & StateProps & DispatchProps> = ({
>
{renderContent()}
</InfiniteScroll>
</div>
);
};

View File

@ -110,6 +110,7 @@ export const STICKER_SIZE_MODAL = 64;
export const STICKER_SIZE_TWO_FA = 160;
export const STICKER_SIZE_DISCUSSION_GROUPS = 140;
export const STICKER_SIZE_FOLDER_SETTINGS = 80;
export const STICKER_SIZE_INLINE_BOT_RESULT = 100;
export const RECENT_STICKERS_LIMIT = 20;
export const MEMOJI_STICKER_ID = 'MEMOJI_STICKER';

View File

@ -140,6 +140,7 @@ function updateCache() {
'currentUserId',
'contactList',
'topPeers',
'topInlineBots',
'recentEmojis',
'push',
'shouldShowContextMenuHint',

View File

@ -73,6 +73,11 @@ export const INITIAL_STATE: GlobalState = {
search: {},
},
inlineBots: {
isLoading: false,
byUsername: {},
},
globalSearch: {},
userSearch: {},
@ -91,6 +96,8 @@ export const INITIAL_STATE: GlobalState = {
topPeers: {},
topInlineBots: {},
mediaViewer: {},
audioPlayer: {},

View File

@ -40,6 +40,7 @@ import {
NotifyException,
LangCode,
EmojiKeywords,
InlineBotSettings,
NewChatMembersProgress,
} from '../types';
@ -225,6 +226,11 @@ export type GlobalState = {
};
};
inlineBots: {
isLoading: boolean;
byUsername: Record<string, false | InlineBotSettings>;
};
globalSearch: {
query?: string;
date?: number;
@ -309,6 +315,12 @@ export type GlobalState = {
lastRequestedAt?: number;
};
topInlineBots: {
hash?: number;
userIds?: number[];
lastRequestedAt?: number;
};
webPagePreview?: ApiWebPage;
forwardMessages: {
@ -474,7 +486,8 @@ export type ActionTypes = (
'faveSticker' | 'unfaveSticker' | 'toggleStickerSet' | 'loadAnimatedEmojis' |
'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' |
// bots
'clickInlineButton' | 'sendBotCommand' |
'clickInlineButton' | 'sendBotCommand' | 'loadTopInlineBots' | 'queryInlineBot' | 'sendInlineBotResult' |
'resetInlineBot' |
// misc
'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' |
'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' |

View File

@ -22,9 +22,11 @@ const { uploadFile } = require('./uploadFile');
const { updateTwoFaSettings } = require('./2fa');
const DEFAULT_DC_ID = 2;
const WEBDOCUMENT_DC_ID = 4;
const DEFAULT_IPV4_IP = 'venus.web.telegram.org';
const DEFAULT_IPV6_IP = '[2001:67c:4e8:f002::a]';
const BORROWED_SENDER_RELEASE_TIMEOUT = 30000; // 30 sec
const WEBDOCUMENT_REQUEST_PART_SIZE = 131072; // 128kb
const PING_INTERVAL = 3000; // 3 sec
const PING_TIMEOUT = 5000; // 5 sec
@ -571,10 +573,41 @@ class TelegramClient {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_downloadWebDocument(media, args) {
throw new Error('not implemented');
async _downloadWebDocument(media) {
try {
const buff = [];
let offset = 0;
while (true) {
const downloaded = new requests.upload.GetWebFile({
location: new constructors.InputWebFileLocation({
url: media.url,
accessHash: media.accessHash,
}),
offset,
limit: WEBDOCUMENT_REQUEST_PART_SIZE,
});
const sender = await this._borrowExportedSender(WEBDOCUMENT_DC_ID);
const res = await sender.send(downloaded);
offset += 131072;
if (res.bytes.length) {
buff.push(res.bytes);
if (res.bytes.length < WEBDOCUMENT_REQUEST_PART_SIZE) {
break;
}
} else {
break;
}
}
return Buffer.concat(buff);
} catch (e) {
// the file is no longer saved in telegram's cache.
if (e.message === 'WEBFILE_NOT_AVAILABLE') {
return Buffer.alloc(0);
} else {
throw e;
}
}
}
// region Invoking Telegram request
/**
* Invokes a MTProtoRequest (sends and receives it) and returns its result

View File

@ -1006,6 +1006,7 @@ messages.searchGlobal#4bc6589a flags:# folder_id:flags.0?int q:string filter:Mes
messages.getDocumentByHash#338e2464 sha256:bytes size:int mime_type:string = Document;
messages.getSavedGifs#83bf3d52 hash:int = messages.SavedGifs;
messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults;
messages.sendInlineBotResult#220815b0 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int = Updates;
messages.editMessage#48f71778 flags:# no_webpage:flags.1?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.15?int = Updates;
messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer;
messages.getPeerDialogs#e470bcfd peers:Vector<InputDialogPeer> = messages.PeerDialogs;
@ -1048,6 +1049,7 @@ photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int
upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool;
upload.getFile#b15a9afc flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:int limit:int = upload.File;
upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool;
upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile;
help.getConfig#c4f9186b = Config;
help.getNearestDc#1fb33026 = NearestDc;
help.getSupport#9cdf08cd = help.Support;

View File

@ -1006,6 +1006,7 @@ messages.searchGlobal#4bc6589a flags:# folder_id:flags.0?int q:string filter:Mes
messages.getDocumentByHash#338e2464 sha256:bytes size:int mime_type:string = Document;
messages.getSavedGifs#83bf3d52 hash:int = messages.SavedGifs;
messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults;
messages.sendInlineBotResult#220815b0 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to_msg_id:flags.0?int random_id:long query_id:long id:string schedule_date:flags.10?int = Updates;
messages.editMessage#48f71778 flags:# no_webpage:flags.1?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.15?int = Updates;
messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer;
messages.getPeerDialogs#e470bcfd peers:Vector<InputDialogPeer> = messages.PeerDialogs;
@ -1048,6 +1049,7 @@ photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int
upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool;
upload.getFile#b15a9afc flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:int limit:int = upload.File;
upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool;
upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile;
help.getConfig#c4f9186b = Config;
help.getNearestDc#1fb33026 = NearestDc;
help.getSupport#9cdf08cd = help.Support;

View File

@ -1,10 +1,22 @@
import { addReducer, getDispatch } from '../../../lib/teact/teactn';
import {
addReducer, getDispatch, getGlobal, setGlobal,
} from '../../../lib/teact/teactn';
import { ApiChat } from '../../../api/types';
import { InlineBotSettings } from '../../../types';
import { RE_TME_INVITE_LINK, RE_TME_LINK } from '../../../config';
import { callApi } from '../../../api/gramjs';
import { selectChatMessage, selectCurrentChat } from '../../selectors';
import {
selectChat, selectChatMessage, selectCurrentChat, selectCurrentMessageList, selectReplyingToId, selectUser,
} from '../../selectors';
import { addChats, addUsers } from '../../reducers';
import { buildCollectionByKey } from '../../../util/iteratees';
import { throttle } from '../../../util/schedulers';
import { replaceInlineBotSettings, replaceInlineBotsIsLoading } from '../../reducers/bots';
const TOP_PEERS_REQUEST_COOLDOWN = 60000; // 1 min
const runThrottledForSearch = throttle((cb) => cb(), 500, false);
addReducer('clickInlineButton', (global, actions, payload) => {
const { button } = payload;
@ -52,9 +64,9 @@ addReducer('clickInlineButton', (global, actions, payload) => {
});
addReducer('sendBotCommand', (global, actions, payload) => {
const { command } = payload;
const { command, chatId } = payload;
const { currentUserId } = global;
const chat = selectCurrentChat(global);
const chat = chatId ? selectChat(global, chatId) : selectCurrentChat(global);
if (!currentUserId || !chat) {
return;
}
@ -62,6 +74,192 @@ addReducer('sendBotCommand', (global, actions, payload) => {
void sendBotCommand(chat, currentUserId, command);
});
addReducer('loadTopInlineBots', (global) => {
const { serverTimeOffset } = global;
const { hash, lastRequestedAt } = global.topInlineBots;
if (lastRequestedAt && Date.now() + serverTimeOffset - lastRequestedAt < TOP_PEERS_REQUEST_COOLDOWN) {
return;
}
(async () => {
const result = await callApi('fetchTopInlineBots', { hash });
if (!result) {
return;
}
const { hash: newHash, ids, users } = result;
let newGlobal = getGlobal();
newGlobal = addUsers(newGlobal, buildCollectionByKey(users, 'id'));
newGlobal = {
...newGlobal,
topInlineBots: {
...newGlobal.topInlineBots,
hash: newHash,
userIds: ids,
lastRequestedAt: Date.now(),
},
};
setGlobal(newGlobal);
})();
});
addReducer('queryInlineBot', ((global, actions, payload) => {
const {
chatId, username, query, offset,
} = payload;
(async () => {
let inlineBotData = global.inlineBots.byUsername[username];
if (inlineBotData === false) {
return;
}
if (inlineBotData === undefined) {
const { user: inlineBot, chat } = await callApi('fetchInlineBot', { username }) || {};
global = getGlobal();
if (!inlineBot || !chat) {
setGlobal(replaceInlineBotSettings(global, username, false));
return;
}
global = addUsers(global, { [inlineBot.id]: inlineBot });
global = addChats(global, { [chat.id]: chat });
inlineBotData = {
id: inlineBot.id,
query: '',
offset: '',
switchPm: undefined,
canLoadMore: true,
results: [],
};
global = replaceInlineBotSettings(global, username, inlineBotData);
setGlobal(global);
}
if (query === inlineBotData.query && !inlineBotData.canLoadMore) {
return;
}
void runThrottledForSearch(() => {
searchInlineBot({
username,
inlineBotData: inlineBotData as InlineBotSettings,
chatId,
query,
offset,
});
});
})();
}));
addReducer('sendInlineBotResult', (global, actions, payload) => {
const { id, queryId } = payload;
const currentMessageList = selectCurrentMessageList(global);
if (!currentMessageList || !id) {
return;
}
const { chatId, threadId } = currentMessageList;
const chat = selectChat(global, chatId)!;
actions.setReplyingToId({ messageId: undefined });
actions.clearWebPagePreview({ chatId, threadId, value: false });
void callApi('sendInlineBotResult', {
chat,
resultId: id,
queryId,
replyingTo: selectReplyingToId(global, chatId, threadId),
});
});
addReducer('resetInlineBot', ((global, actions, payload) => {
const { username } = payload;
let inlineBotData = global.inlineBots.byUsername[username];
if (!inlineBotData) {
return;
}
inlineBotData = {
id: inlineBotData.id,
query: '',
offset: '',
switchPm: undefined,
canLoadMore: true,
results: [],
};
setGlobal(replaceInlineBotSettings(global, username, inlineBotData));
}));
async function searchInlineBot({
username,
inlineBotData,
chatId,
query,
offset,
} : {
username: string;
inlineBotData: InlineBotSettings;
chatId: number;
query: string;
offset?: string;
}) {
let global = getGlobal();
const bot = selectUser(global, inlineBotData.id);
const chat = selectChat(global, chatId);
if (!bot || !chat) {
return;
}
global = replaceInlineBotsIsLoading(global, true);
global = replaceInlineBotSettings(global, username, {
...inlineBotData,
query,
...(inlineBotData && inlineBotData.query !== query && { offset: undefined }),
});
setGlobal(global);
const result = await callApi('fetchInlineBotResults', {
bot,
chat,
query,
offset,
});
const newInlineBotData = global.inlineBots.byUsername[username];
global = replaceInlineBotsIsLoading(getGlobal(), false);
if (!result || !newInlineBotData || query !== newInlineBotData.query) {
setGlobal(global);
return;
}
const currentIds = new Set((newInlineBotData.results || []).map((data) => data.id));
const newResults = result.results.filter((data) => !currentIds.has(data.id));
global = replaceInlineBotSettings(global, username, {
...newInlineBotData,
help: result.help,
isGallery: result.isGallery,
switchPm: result.switchPm,
canLoadMore: result.results.length > 0 && Boolean(result.nextOffset),
results: newInlineBotData.offset === '' || newInlineBotData.offset === result.nextOffset
? result.results
: (newInlineBotData.results || []).concat(newResults),
offset: newResults.length ? result.nextOffset : '',
});
setGlobal(global);
}
async function sendBotCommand(chat: ApiChat, currentUserId: number, command: string) {
await callApi('sendMessage', {
chat,

View File

@ -48,7 +48,7 @@ export function getMessageSummaryText(lang: LangFn, message: ApiMessage, noEmoji
}
if (sticker) {
return `${sticker.emoji} ${lang('AttachSticker')} `;
return `${sticker.emoji || ''} ${lang('AttachSticker')}`.trim();
}
if (audio) {

View File

@ -0,0 +1,28 @@
import { GlobalState } from '../../global/types';
import { InlineBotSettings } from '../../types';
export function replaceInlineBotSettings(
global: GlobalState, username: string, inlineBotSettings: InlineBotSettings | false,
): GlobalState {
return {
...global,
inlineBots: {
...global.inlineBots,
byUsername: {
...global.inlineBots.byUsername,
[username]: inlineBotSettings,
},
},
};
}
export function replaceInlineBotsIsLoading(global: GlobalState, isLoading: boolean): GlobalState {
return {
...global,
inlineBots: {
...global.inlineBots,
isLoading,
},
};
}

View File

@ -1,4 +1,5 @@
import {
ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm,
ApiLanguage, ApiMessage, ApiShippingAddress, ApiStickerSet,
} from '../api/types';
@ -305,3 +306,14 @@ export type EmojiKeywords = {
version: number;
keywords: Record<string, string[]>;
};
export type InlineBotSettings = {
id: number;
help?: string;
query?: string;
offset?: string;
canLoadMore?: boolean;
results?: (ApiBotInlineResult | ApiBotInlineMediaResult)[];
isGallery?: boolean;
switchPm?: ApiBotInlineSwitchPm;
};

View File

@ -13,7 +13,8 @@ export async function fetch(cacheName: string, key: string, type: Type) {
}
try {
const request = new Request(key);
// To avoid the error "Request scheme 'webdocument' is unsupported"
const request = new Request(key.replace(/:/g, '_'));
const cache = await cacheApi.open(cacheName);
const response = await cache.match(request);
if (!response) {
@ -60,7 +61,8 @@ export async function save(cacheName: string, key: string, data: AnyLiteral | Bl
try {
const cacheData = typeof data === 'string' || data instanceof Blob ? data : JSON.stringify(data);
const request = new Request(key);
// To avoid the error "Request scheme 'webdocument' is unsupported"
const request = new Request(key.replace(/:/g, '_'));
const response = new Response(cacheData);
const cache = await cacheApi.open(cacheName);
return await cache.put(request, response);

View File

@ -1,16 +1,22 @@
import { IS_TOUCH_ENV } from './environment';
export default function focusEditableElement(element: HTMLElement, force?: boolean) {
if (!force && element === document.activeElement) {
return;
}
const selection = window.getSelection()!;
const range = document.createRange();
const lastChild = element.lastChild || element;
if (!element.lastChild || !element.lastChild.nodeValue) {
if (!IS_TOUCH_ENV && (!lastChild || !lastChild.nodeValue)) {
element.focus();
return;
}
range.setStart(element.lastChild, element.lastChild.nodeValue.length);
range.selectNodeContents(lastChild);
// `false` means collapse to the end rather than the start
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}

View File

@ -11,8 +11,10 @@ export default function insertHtmlInSelection(html: string) {
if (lastInsertedNode) {
range.setStartAfter(lastInsertedNode);
range.setEndAfter(lastInsertedNode);
selection.removeAllRanges();
selection.addRange(range);
} else {
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
}

View File

@ -0,0 +1,30 @@
import findInViewport from './findInViewport';
import isFullyVisible from './isFullyVisible';
import fastSmoothScroll from './fastSmoothScroll';
const VIEWPORT_MARGIN = 8;
const SCROLL_MARGIN = 10;
export default function setTooltipItemVisible(selector: string, index: number, containerRef: Record<string, any>) {
const container = containerRef.current!;
if (!container || index < 0) {
return;
}
const { visibleIndexes, allElements } = findInViewport(
container,
selector,
VIEWPORT_MARGIN,
true,
true,
);
if (!allElements.length || !allElements[index]) {
return;
}
const first = visibleIndexes[0];
if (!visibleIndexes.includes(index)
|| (index === first && !isFullyVisible(container, allElements[first]))) {
const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end';
fastSmoothScroll(container, allElements[index], position, SCROLL_MARGIN);
}
}