Composer: Implement Inline Bots (#1206)
This commit is contained in:
parent
b44792afab
commit
c3fd0d66e9
51
src/api/gramjs/apiBuilders/bots.ts
Normal file
51
src/api/gramjs/apiBuilders/bots.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ export {
|
||||
} from './twoFaSettings';
|
||||
|
||||
export {
|
||||
answerCallbackButton,
|
||||
answerCallbackButton, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults, sendInlineBotResult,
|
||||
} from './bots';
|
||||
|
||||
export {
|
||||
|
||||
@ -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
40
src/api/types/bots.ts
Normal 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;
|
||||
}
|
||||
@ -5,4 +5,5 @@ export * from './updates';
|
||||
export * from './media';
|
||||
export * from './payments';
|
||||
export * from './settings';
|
||||
export * from './bots';
|
||||
export * from './misc';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -15,6 +15,7 @@ export interface ApiUser {
|
||||
accessHash?: string;
|
||||
avatarHash?: string;
|
||||
photos?: ApiPhoto[];
|
||||
botPlaceholder?: string;
|
||||
canBeInvitedToGroup?: boolean;
|
||||
|
||||
// Obtained from GetFullUser / UserFullInfo
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -18,10 +18,9 @@
|
||||
|
||||
.Badge-wrapper {
|
||||
display: flex;
|
||||
margin-left: 1.5rem;
|
||||
|
||||
.Badge {
|
||||
margin-left: 0.5rem;
|
||||
margin-inline-start: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
|
||||
15
src/components/middle/composer/InlineBotTooltip.async.tsx
Normal file
15
src/components/middle/composer/InlineBotTooltip.async.tsx
Normal 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);
|
||||
54
src/components/middle/composer/InlineBotTooltip.scss
Normal file
54
src/components/middle/composer/InlineBotTooltip.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
src/components/middle/composer/InlineBotTooltip.tsx
Normal file
225
src/components/middle/composer/InlineBotTooltip.tsx
Normal 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));
|
||||
@ -23,7 +23,6 @@
|
||||
.title {
|
||||
margin-inline-end: .625rem;
|
||||
max-width: 70%;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.handle {
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
88
src/components/middle/composer/hooks/useInlineBotTooltip.ts
Normal file
88
src/components/middle/composer/hooks/useInlineBotTooltip.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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} `);
|
||||
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('@');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
62
src/components/middle/composer/inlineResults/BaseResult.scss
Normal file
62
src/components/middle/composer/inlineResults/BaseResult.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/components/middle/composer/inlineResults/BaseResult.tsx
Normal file
62
src/components/middle/composer/inlineResults/BaseResult.tsx
Normal 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);
|
||||
40
src/components/middle/composer/inlineResults/GifResult.tsx
Normal file
40
src/components/middle/composer/inlineResults/GifResult.tsx
Normal 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);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
76
src/components/middle/composer/inlineResults/MediaResult.tsx
Normal file
76
src/components/middle/composer/inlineResults/MediaResult.tsx
Normal 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);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -119,7 +119,6 @@ const GifSearch: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
>
|
||||
{renderContent()}
|
||||
</InfiniteScroll>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -140,6 +140,7 @@ function updateCache() {
|
||||
'currentUserId',
|
||||
'contactList',
|
||||
'topPeers',
|
||||
'topInlineBots',
|
||||
'recentEmojis',
|
||||
'push',
|
||||
'shouldShowContextMenuHint',
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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' |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
28
src/modules/reducers/bots.ts
Normal file
28
src/modules/reducers/bots.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
30
src/util/setTooltipItemVisible.ts
Normal file
30
src/util/setTooltipItemVisible.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user