import type { ApiAttachment, ApiChat, ApiChatType, ApiDraft, ApiError, ApiInputMessageReplyInfo, ApiInputStoryReplyInfo, ApiMessage, ApiOnProgress, ApiStory, ApiUser, } from '../../../api/types'; import type { ForwardMessagesParams, SendMessageParams, ThreadId, } from '../../../types'; import type { MessageKey } from '../../../util/keys/messageKey'; import type { RequiredGlobalActions } from '../../index'; import type { ActionReturnType, GlobalState, TabArgs, } from '../../types'; import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../../api/types'; import { LoadMoreDirection } from '../../../types'; import { GIF_MIME_TYPE, MAX_MEDIA_FILES_FOR_ALBUM, MESSAGE_ID_REQUIRED_ERROR, MESSAGE_LIST_SLICE, RE_TELEGRAM_LINK, SERVICE_NOTIFICATIONS_USER_ID, SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; import { ensureProtocol, isMixedScriptUrl } from '../../../util/browser/url'; import { IS_IOS } from '../../../util/browser/windowEnvironment'; import { copyTextToClipboardFromPromise } from '../../../util/clipboard'; import { isDeepLink } from '../../../util/deepLinkParser'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { areSortedArraysIntersecting, buildCollectionByKey, omit, partition, split, unique, } from '../../../util/iteratees'; import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey'; import { getTranslationFn, type RegularLangFnParameters } from '../../../util/localization'; import { formatStarsAsText } from '../../../util/localization/format'; import { oldTranslate } from '../../../util/oldLangProvider'; import { debounce, onTickEnd, rafPromise } from '../../../util/schedulers'; import { callApi, cancelApiProgress } from '../../../api/gramjs'; import { getIsSavedDialog, getUserFullName, isChatChannel, isChatSuperGroup, isDeletedUser, isMessageLocal, isServiceNotificationMessage, isUserBot, splitMessagesForForwarding, } from '../../helpers'; import { isApiPeerChat, isApiPeerUser } from '../../helpers/peers'; import { addActionHandler, getActions, getGlobal, setGlobal, } from '../../index'; import { addChatMessagesById, addUnreadMentions, deleteSponsoredMessage, removeOutlyingList, removeRequestedMessageTranslation, removeUnreadMentions, replaceSettings, replaceThreadParam, replaceUserStatuses, safeReplacePinnedIds, safeReplaceViewportIds, updateChat, updateChatFullInfo, updateChatMessage, updateGlobalSearch, updateListedIds, updateMessageTranslation, updateOutlyingLists, updatePeerFullInfo, updateQuickReplies, updateQuickReplyMessages, updateRequestedMessageTranslation, updateScheduledMessages, updateSponsoredMessage, updateThreadInfo, updateThreadUnreadFromForwardedMessage, updateTopic, updateUploadByMessageKey, updateUserFullInfo, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; import { selectCanForwardMessage, selectChat, selectChatFullInfo, selectChatLastMessageId, selectChatMessage, selectCurrentChat, selectCurrentMessageList, selectCurrentViewedStory, selectDraft, selectEditingId, selectEditingMessage, selectEditingScheduledId, selectFirstMessageId, selectFirstUnreadId, selectFocusedMessageId, selectForwardsCanBeSentToChat, selectForwardsContainVoiceMessages, selectIsChatBotNotStarted, selectIsChatWithSelf, selectIsCurrentUserFrozen, selectIsCurrentUserPremium, selectLanguageCode, selectListedIds, selectMessageReplyInfo, selectNoWebPage, selectOutlyingListByMessageId, selectPeer, selectPeerStory, selectPinnedIds, selectPollFromMessage, selectRealLastReadId, selectReplyCanBeSentToChat, selectScheduledMessage, selectSendAs, selectTabState, selectThreadIdFromMessage, selectThreadInfo, selectTopic, selectTranslationLanguage, selectUser, selectUserFullInfo, selectUserStatus, selectViewportIds, } from '../../selectors'; import { deleteMessages } from '../apiUpdaters/messages'; const AUTOLOGIN_TOKEN_KEY = 'autologin_token'; const uploadProgressCallbacks = new Map(); const runDebouncedForMarkRead = debounce((cb) => cb(), 500, false); addActionHandler('loadViewportMessages', (global, actions, payload): ActionReturnType => { const { direction = LoadMoreDirection.Around, isBudgetPreload = false, shouldForceRender = false, onLoaded, onError, tabId = getCurrentTabId(), } = payload || {}; let { chatId, threadId } = payload || {}; if (!chatId || !threadId) { const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { onError?.(); return; } chatId = currentMessageList.chatId; threadId = currentMessageList.threadId; } const chat = selectChat(global, chatId); // TODO Revise if `chat.isRestricted` check is needed if (!chat || chat.isRestricted) { onError?.(); return; } const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const listedIds = selectListedIds(global, chatId, threadId); if (!viewportIds || !viewportIds.length || direction === LoadMoreDirection.Around) { const offsetId = selectFocusedMessageId(global, chatId, tabId) || selectRealLastReadId(global, chatId, threadId); const isOutlying = Boolean(offsetId && listedIds && !listedIds.includes(offsetId)); const historyIds = (isOutlying ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId!) : listedIds) || []; const { newViewportIds, areSomeLocal, areAllLocal, } = getViewportSlice(historyIds, offsetId, LoadMoreDirection.Around); if (areSomeLocal) { global = safeReplaceViewportIds(global, chatId, threadId, newViewportIds, tabId); } if (!areAllLocal) { onTickEnd(() => { void loadViewportMessages( global, chat, threadId, offsetId, LoadMoreDirection.Around, isOutlying, isBudgetPreload, onLoaded, tabId, ); }); } else { onLoaded?.(); } } else { const offsetId = direction === LoadMoreDirection.Backwards ? viewportIds[0] : viewportIds[viewportIds.length - 1]; // Prevent requests with local offsets if (isLocalMessageId(offsetId)) return; // Prevent unnecessary requests in threads if (offsetId === threadId && direction === LoadMoreDirection.Backwards) return; const isOutlying = Boolean(listedIds && !listedIds.includes(offsetId)); const historyIds = (isOutlying ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : listedIds)!; if (historyIds?.length) { const { newViewportIds, areSomeLocal, areAllLocal, } = getViewportSlice(historyIds, offsetId, direction); if (areSomeLocal) { global = safeReplaceViewportIds(global, chatId, threadId, newViewportIds, tabId); } onTickEnd(() => { void loadWithBudget( global, actions, areAllLocal, isOutlying, isBudgetPreload, chat, threadId, direction, offsetId, onLoaded, tabId, ); }); } if (isBudgetPreload) { return; } } setGlobal(global, { forceOnHeavyAnimation: shouldForceRender }); }); async function loadWithBudget( global: T, actions: RequiredGlobalActions, areAllLocal: boolean, isOutlying: boolean, isBudgetPreload: boolean, chat: ApiChat, threadId: ThreadId, direction: LoadMoreDirection, offsetId?: number, onLoaded?: NoneToVoidFunction, ...[tabId = getCurrentTabId()]: TabArgs ) { if (!areAllLocal) { await loadViewportMessages( global, chat, threadId, offsetId, direction, isOutlying, isBudgetPreload, onLoaded, tabId, ); } if (!isBudgetPreload) { actions.loadViewportMessages({ chatId: chat.id, threadId, direction, isBudgetPreload: true, onLoaded, tabId, }); } } addActionHandler('loadMessage', async (global, actions, payload): Promise => { const { chatId, messageId, replyOriginForId, threadUpdate, } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } const message = await loadMessage(global, chat, messageId, replyOriginForId); if (message && threadUpdate) { const { lastMessageId, isDeleting } = threadUpdate; global = getGlobal(); global = updateThreadUnreadFromForwardedMessage( global, message, chatId, lastMessageId, isDeleting, ); setGlobal(global); } }); addActionHandler('sendMessage', async (global, actions, payload): Promise => { const { messageList, tabId = getCurrentTabId() } = payload; const { storyId, peerId: storyPeerId } = selectCurrentViewedStory(global, tabId); const isStoryReply = Boolean(storyId && storyPeerId); if (!messageList && !isStoryReply) { return; } let { chatId, threadId, type } = messageList || {}; if (isStoryReply) { chatId = storyPeerId!; threadId = MAIN_THREAD_ID; type = 'thread'; } payload = omit(payload, ['tabId']); if (type === 'scheduled' && !payload.scheduledAt) { global = updateTabState(global, { contentToBeScheduled: payload, }, tabId); setGlobal(global); return; } const chat = selectChat(global, chatId!)!; const draft = selectDraft(global, chatId!, threadId!); const isForwarding = selectTabState(global, tabId).forwardMessages?.messageIds?.length; const draftReplyInfo = !isForwarding && !isStoryReply ? draft?.replyInfo : undefined; const storyReplyInfo = isStoryReply ? { type: 'story', peerId: storyPeerId!, storyId: storyId!, } satisfies ApiInputStoryReplyInfo : undefined; const messageReplyInfo = selectMessageReplyInfo(global, chatId!, threadId!, draftReplyInfo); const replyInfo = storyReplyInfo || messageReplyInfo; const threadInfo = selectThreadInfo(global, chatId!, threadId!); const lastMessageId = threadId === MAIN_THREAD_ID ? selectChatLastMessageId(global, chatId!) : threadInfo?.lastMessageId; const messagePriceInStars = await getPeerStarsForMessage(global, chatId!); const params: SendMessageParams = { ...payload, chat, replyInfo, noWebPage: selectNoWebPage(global, chatId!, threadId!), sendAs: selectSendAs(global, chatId!), lastMessageId, messagePriceInStars, isStoryReply, isPending: messagePriceInStars ? true : undefined, }; if (!isStoryReply) { actions.clearWebPagePreview({ tabId }); } const isSingle = (!payload.attachments || payload.attachments.length <= 1) && !isForwarding; const isGrouped = !isSingle && payload.shouldGroupMessages; const localMessages: SendMessageParams[] = []; if (isSingle) { const { attachments, ...restParams } = params; const sendParams: SendMessageParams = { ...restParams, attachment: attachments ? attachments[0] : undefined, wasDrafted: Boolean(draft), }; await sendMessageOrReduceLocal(global, sendParams, localMessages); } else if (isGrouped) { const { text, entities, attachments, ...commonParams } = params; const byType = splitAttachmentsByType(attachments!); let hasSentCaption = false; for (let groupIndex = 0; groupIndex < byType.length; groupIndex++) { const group = byType[groupIndex]; const groupedAttachments = split(group, MAX_MEDIA_FILES_FOR_ALBUM); for (let i = 0; i < groupedAttachments.length; i++) { const groupedId = `${Date.now()}${groupIndex}${i}`; const isFirst = i === 0 && groupIndex === 0; const isLast = i === groupedAttachments.length - 1 && groupIndex === byType.length - 1; if (group[0].quick && !group[0].shouldSendAsFile) { const [firstAttachment, ...restAttachments] = groupedAttachments[i]; let sendParams: SendMessageParams = { ...commonParams, text: isFirst && !hasSentCaption ? text : undefined, entities: isFirst && !hasSentCaption ? entities : undefined, attachment: firstAttachment, groupedId: restAttachments.length > 0 ? groupedId : undefined, wasDrafted: Boolean(draft), }; await sendMessageOrReduceLocal(global, sendParams, localMessages); hasSentCaption = true; for (const attachment of restAttachments) { sendParams = { ...commonParams, attachment, groupedId, }; await sendMessageOrReduceLocal(global, sendParams, localMessages); } } else { const firstAttachments = groupedAttachments[i].slice(0, -1); const lastAttachment = groupedAttachments[i][groupedAttachments[i].length - 1]; for (const attachment of firstAttachments) { const sendParams = { ...commonParams, attachment, groupedId, }; await sendMessageOrReduceLocal(global, sendParams, localMessages); } const sendParams = { ...commonParams, text: isLast && !hasSentCaption ? text : undefined, entities: isLast && !hasSentCaption ? entities : undefined, attachment: lastAttachment, groupedId: firstAttachments.length > 0 ? groupedId : undefined, wasDrafted: Boolean(draft), }; await sendMessageOrReduceLocal(global, sendParams, localMessages); hasSentCaption = true; } } } } else { const { text, entities, attachments, replyInfo: replyToForFirstMessage, ...commonParams } = params; if (text) { const sendParams = { ...commonParams, text, entities, replyInfo: replyToForFirstMessage, wasDrafted: Boolean(draft), }; await sendMessageOrReduceLocal(global, sendParams, localMessages); } if (attachments) { for (const attachment of attachments) { const sendParams = { ...commonParams, attachment, }; await sendMessageOrReduceLocal(global, sendParams, localMessages); } } } if (isForwarding) { const localForwards = await executeForwardMessages(global, params, tabId); if (localForwards) { localMessages.push(...localForwards); } } if (localMessages?.length) sendMessagesWithNotification(global, localMessages); }); addActionHandler('sendInviteMessages', async (global, actions, payload): Promise => { const { chatId, userIds, tabId = getCurrentTabId() } = payload; const chatFullInfo = selectChatFullInfo(global, chatId); if (!chatFullInfo?.inviteLink) { return undefined; } const userFullNames: string[] = []; await Promise.all(userIds.map((userId) => { const chat = selectChat(global, userId); if (!chat) { return undefined; } const userFullName = getUserFullName(selectUser(global, userId)); if (userFullName) { userFullNames.push(userFullName); } return sendMessage(global, { chat, text: chatFullInfo.inviteLink, }); })); return actions.showNotification({ message: oldTranslate('Conversation.ShareLinkTooltip.Chat.One', userFullNames.join(', ')), tabId, }); }); addActionHandler('editMessage', (global, actions, payload): ActionReturnType => { const { messageList, text, entities, attachments, tabId = getCurrentTabId(), } = payload; if (!messageList) { return; } let currentMessageKey: MessageKey | undefined; const progressCallback = attachments ? (progress: number, messageKey: MessageKey) => { if (!uploadProgressCallbacks.has(messageKey)) { currentMessageKey = messageKey; uploadProgressCallbacks.set(messageKey, progressCallback!); } global = getGlobal(); global = updateUploadByMessageKey(global, messageKey, progress); setGlobal(global); } : undefined; const { chatId, threadId, type: messageListType } = messageList; const chat = selectChat(global, chatId); const message = selectEditingMessage(global, chatId, threadId, messageListType); if (!chat || !message) { return; } actions.setEditingId({ messageId: undefined, tabId }); (async () => { await callApi('editMessage', { chat, message, attachment: attachments ? attachments[0] : undefined, text, entities, noWebPage: selectNoWebPage(global, chatId, threadId), }, progressCallback); if (progressCallback && currentMessageKey) { global = getGlobal(); global = updateUploadByMessageKey(global, currentMessageKey, undefined); setGlobal(global); uploadProgressCallbacks.delete(currentMessageKey); } })(); }); addActionHandler('cancelUploadMedia', (global, actions, payload): ActionReturnType => { const { chatId, messageId } = payload; const message = selectChatMessage(global, chatId, messageId); if (!message) return; const progressCallback = message && uploadProgressCallbacks.get(getMessageKey(message)); if (progressCallback) { cancelApiProgress(progressCallback); } if (isMessageLocal(message)) { actions.apiUpdate({ '@type': 'deleteMessages', ids: [messageId], chatId, }); } }); addActionHandler('saveDraft', (global, actions, payload): ActionReturnType => { const { chatId, threadId, text, } = payload; const chat = selectChat(global, chatId); if (!text || !chat) { return; } const currentDraft = selectDraft(global, chatId, threadId); if (chat.isMonoforum && !currentDraft?.replyInfo) { return; // Monoforum doesn't support drafts outside threads } const newDraft: ApiDraft = { text, replyInfo: currentDraft?.replyInfo, effectId: currentDraft?.effectId, }; saveDraft({ global, chatId, threadId, draft: newDraft, }); }); addActionHandler('clearDraft', (global, actions, payload): ActionReturnType => { const { chatId, threadId = MAIN_THREAD_ID, isLocalOnly, shouldKeepReply, } = payload; const currentDraft = selectDraft(global, chatId, threadId); if (!currentDraft) { return; } const currentReplyInfo = currentDraft.replyInfo; const newDraft: ApiDraft | undefined = shouldKeepReply && currentReplyInfo ? { replyInfo: currentReplyInfo, } : undefined; saveDraft({ global, chatId, threadId, draft: newDraft, isLocalOnly, }); }); addActionHandler('updateDraftReplyInfo', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId(), ...update } = payload; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return; } const { chatId, threadId } = currentMessageList; const currentDraft = selectDraft(global, chatId, threadId); const updatedReplyInfo = { type: 'message', ...currentDraft?.replyInfo, ...update, } as ApiInputMessageReplyInfo; if (!updatedReplyInfo.replyToMsgId) return; const newDraft: ApiDraft = { ...currentDraft, replyInfo: updatedReplyInfo, }; saveDraft({ global, chatId, threadId, draft: newDraft, isLocalOnly: true, noLocalTimeUpdate: true, }); }); addActionHandler('resetDraftReplyInfo', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return; } const { chatId, threadId } = currentMessageList; const chat = selectChat(global, chatId); const currentDraft = selectDraft(global, chatId, threadId); if (chat?.isMonoforum && !currentDraft?.replyInfo) { return; // Monoforum doesn't support drafts outside threads } const newDraft: ApiDraft | undefined = !currentDraft?.text ? undefined : { ...currentDraft, replyInfo: undefined, }; saveDraft({ global, chatId, threadId, draft: newDraft, isLocalOnly: Boolean(newDraft), }); }); addActionHandler('saveEffectInDraft', (global, actions, payload): ActionReturnType => { const { chatId, threadId, effectId, } = payload; const chat = selectChat(global, chatId); const currentDraft = selectDraft(global, chatId, threadId); if (chat?.isMonoforum && !currentDraft?.replyInfo) { return; // Monoforum doesn't support drafts outside threads } const newDraft = { ...currentDraft, effectId, }; saveDraft({ global, chatId, threadId, draft: newDraft, isLocalOnly: true, noLocalTimeUpdate: true, }); }); addActionHandler('updateInsertingPeerIdMention', (global, actions, payload): ActionReturnType => { const { peerId, tabId = getCurrentTabId() } = payload || {}; return updateTabState(global, { insertingPeerIdMention: peerId, }, tabId); }); async function saveDraft({ global, chatId, threadId, draft, isLocalOnly, noLocalTimeUpdate, }: { global: T; chatId: string; threadId: ThreadId; draft?: ApiDraft; isLocalOnly?: boolean; noLocalTimeUpdate?: boolean; }) { const chat = selectChat(global, chatId); const user = selectUser(global, chatId); if (!chat || (user && isDeletedUser(user))) return; const replyInfo = selectMessageReplyInfo(global, chatId, threadId, draft?.replyInfo); const newDraft: ApiDraft | undefined = draft ? { ...draft, replyInfo, date: Math.floor(Date.now() / 1000), isLocal: true, } : undefined; global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft); if (!noLocalTimeUpdate) { global = updateChat(global, chatId, { draftDate: newDraft?.date }); } setGlobal(global); if (isLocalOnly) return; const result = await callApi('saveDraft', { chat, draft: newDraft, }); if (result && newDraft) { newDraft.isLocal = false; } global = getGlobal(); global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft); global = updateChat(global, chatId, { draftDate: newDraft?.date }); setGlobal(global); } addActionHandler('toggleMessageWebPage', (global, actions, payload): ActionReturnType => { const { chatId, threadId, noWebPage } = payload; return replaceThreadParam(global, chatId, threadId, 'noWebPage', noWebPage); }); addActionHandler('pinMessage', (global, actions, payload): ActionReturnType => { const { chatId, messageId, isUnpin, isOneSide, isSilent, } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } void callApi('pinMessage', { chat, messageId, isUnpin, isOneSide, isSilent, }); }); addActionHandler('unpinAllMessages', async (global, actions, payload): Promise => { const { chatId, threadId } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } await callApi('unpinAllMessages', { chat, threadId }); global = getGlobal(); const pinnedIds = selectPinnedIds(global, chatId, threadId); pinnedIds?.forEach((id) => { global = updateChatMessage(global, chatId, id, { isPinned: false }); }); global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'pinnedIds', []); setGlobal(global); }); addActionHandler('deleteMessages', (global, actions, payload): ActionReturnType => { const { messageIds, shouldDeleteForAll, messageList: payloadMessageList, tabId = getCurrentTabId(), } = payload; const currentMessageList = selectCurrentMessageList(global, tabId); const messageList = payloadMessageList || currentMessageList; if (!messageList) { return; } const { chatId, threadId } = messageList; const chat = selectChat(global, chatId)!; const messageIdsToDelete = messageIds.filter((id) => { const message = selectChatMessage(global, chatId, id); return message && !isMessageLocal(message); }); // Only local messages if (!messageIdsToDelete.length && messageIds.length) { deleteMessages(global, isChatChannel(chat) || isChatSuperGroup(chat) ? chatId : undefined, messageIds, actions); return; } void callApi('deleteMessages', { chat, messageIds: messageIdsToDelete, shouldDeleteForAll }); const editingId = selectEditingId(global, chatId, threadId); if (editingId && messageIds.includes(editingId)) { actions.setEditingId({ messageId: undefined, tabId }); } }); addActionHandler('resetLocalPaidMessages', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const notifications = selectTabState(global, tabId).notifications; if (!notifications || !notifications.length) return global; notifications.forEach((notification) => { if (notification.type === 'paidMessage') { const action = notification.dismissAction; if (action && !Array.isArray(action)) { // @ts-ignore actions[action.action](action.payload); } actions.dismissNotification({ localId: notification.localId, tabId }); } }); return global; }); addActionHandler('deleteParticipantHistory', (global, actions, payload): ActionReturnType => { const { chatId, peerId, } = payload; const chat = selectChat(global, chatId)!; const peer = selectPeer(global, peerId)!; void callApi('deleteParticipantHistory', { chat, peer }); }); addActionHandler('deleteScheduledMessages', (global, actions, payload): ActionReturnType => { const { messageIds, tabId = getCurrentTabId() } = payload; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return; } const { chatId } = currentMessageList; const chat = selectChat(global, chatId)!; void callApi('deleteScheduledMessages', { chat, messageIds }); const editingId = selectEditingScheduledId(global, chatId); if (editingId && messageIds.includes(editingId)) { actions.setEditingId({ messageId: undefined, tabId }); } }); addActionHandler('deleteHistory', async (global, actions, payload): Promise => { const { chatId, shouldDeleteForAll, tabId = getCurrentTabId() } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } await callApi('deleteHistory', { chat, shouldDeleteForAll }); global = getGlobal(); const activeChat = selectCurrentMessageList(global, tabId); if (activeChat && activeChat.chatId === chatId) { actions.openChat({ id: undefined, tabId }); } // Delete chat from folders const folders = global.chatFolders.byId; Object.values(folders).forEach((folder) => { if (folder.includedChatIds.includes(chatId) || folder.pinnedChatIds?.includes(chatId)) { const newIncludedChatIds = folder.includedChatIds.filter((id) => id !== chatId); const newPinnedChatIds = folder.pinnedChatIds?.filter((id) => id !== chatId); const updatedFolder = { ...folder, includedChatIds: newIncludedChatIds, pinnedChatIds: newPinnedChatIds, }; callApi('editChatFolder', { id: folder.id, folderUpdate: updatedFolder, }); } }); }); addActionHandler('deleteSavedHistory', async (global, actions, payload): Promise => { const { chatId, tabId = getCurrentTabId() } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } await callApi('deleteSavedHistory', { chat }); global = getGlobal(); const activeChat = selectCurrentMessageList(global, tabId); if (activeChat && activeChat.threadId === chatId) { actions.openChat({ id: undefined, tabId }); } }); addActionHandler('reportMessages', async (global, actions, payload): Promise => { const { messageIds, description = '', option = '', chatId, tabId = getCurrentTabId(), } = payload; const chat = selectChat(global, chatId)!; const response = await callApi('reportMessages', { peer: chat, messageIds, description, option, }); if (!response) return; const { result, error } = response; if (error === MESSAGE_ID_REQUIRED_ERROR) { actions.showNotification({ message: oldTranslate('lng_report_please_select_messages'), tabId, }); actions.closeReportModal({ tabId }); return; } if (!result) return; if (result.type === 'reported') { actions.showNotification({ message: result ? oldTranslate('ReportPeer.AlertSuccess') : 'An error occurred while submitting your report. Please, try again later.', tabId, }); actions.closeReportModal({ tabId }); return; } if (result.type === 'selectOption') { global = getGlobal(); const oldSections = selectTabState(global, tabId).reportModal?.sections; const selectedOption = oldSections?.[oldSections.length - 1]?.options?.find((o) => o.option === option); const newSection = { title: result.title, options: result.options, subtitle: selectedOption?.text, }; global = updateTabState(global, { reportModal: { chatId, messageIds, description, subject: 'message', sections: oldSections ? [...oldSections, newSection] : [newSection], }, }, tabId); setGlobal(global); } if (result.type === 'comment') { global = getGlobal(); const oldSections = selectTabState(global, tabId).reportModal?.sections; const selectedOption = oldSections?.[oldSections.length - 1]?.options?.find((o) => o.option === option); const newSection = { isOptional: result.isOptional, option: result.option, title: selectedOption?.text, }; global = updateTabState(global, { reportModal: { chatId, messageIds, description, subject: 'message', sections: oldSections ? [...oldSections, newSection] : [newSection], }, }, tabId); setGlobal(global); } }); addActionHandler('sendMessageAction', async (global, actions, payload): Promise => { const { action, chatId, threadId } = payload; if (global.connectionState !== 'connectionStateReady') return; if (selectIsChatWithSelf(global, chatId)) return; const chat = selectChat(global, chatId)!; if (!chat || chat.isMonoforum) return; const user = selectUser(global, chatId); if (user && (isUserBot(user) || isDeletedUser(user))) return; await callApi('sendMessageAction', { peer: chat, threadId, action, }); }); addActionHandler('reportChannelSpam', (global, actions, payload): ActionReturnType => { const { participantId, chatId, messageIds } = payload; const peer = selectPeer(global, participantId); const chat = selectChat(global, chatId); if (!peer || !chat) { return; } void callApi('reportChannelSpam', { peer, chat, messageIds }); }); addActionHandler('markMessageListRead', (global, actions, payload): ActionReturnType => { if (selectIsCurrentUserFrozen(global)) return undefined; const { maxId, tabId = getCurrentTabId() } = payload; const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { return undefined; } const { chatId, threadId } = currentMessageList; const chat = selectChat(global, chatId); if (!chat || getIsSavedDialog(chatId, threadId, global.currentUserId)) { return undefined; } runDebouncedForMarkRead(() => { void callApi('markMessageListRead', { chat, threadId, maxId, }); }); if (chatId === SERVICE_NOTIFICATIONS_USER_ID) { global = { ...global, serviceNotifications: global.serviceNotifications.map((notification) => { return notification.isUnread && notification.id <= maxId ? { ...notification, isUnread: false } : notification; }), }; } const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const minId = selectFirstUnreadId(global, chatId, threadId); if (threadId !== MAIN_THREAD_ID && !chat.isForum) { global = updateThreadInfo(global, chatId, threadId, { lastReadInboxMessageId: maxId, }); return global; } if (!viewportIds || !minId || !chat.unreadCount) { return global; } const readCount = countSortedIds(viewportIds, minId, maxId); if (!readCount) { return global; } const topic = selectTopic(global, chatId, threadId); if (chat.isForum && topic) { global = updateThreadInfo(global, chatId, threadId, { lastReadInboxMessageId: maxId, }); const newTopicUnreadCount = Math.max(0, topic.unreadCount - readCount); if (newTopicUnreadCount === 0) { global = updateChat(global, chatId, { unreadCount: Math.max(0, chat.unreadCount - 1), }); } return updateTopic(global, chatId, Number(threadId), { unreadCount: newTopicUnreadCount, }); } return updateChat(global, chatId, { lastReadInboxMessageId: maxId, unreadCount: Math.max(0, chat.unreadCount - readCount), }); }); addActionHandler('markMessagesRead', (global, actions, payload): ActionReturnType => { const { messageIds, tabId = getCurrentTabId(), shouldFetchUnreadReactions } = payload; const chat = selectCurrentChat(global, tabId); if (!chat) { return; } void callApi('markMessagesRead', { chat, messageIds }) .then(() => { if (shouldFetchUnreadReactions) { actions.fetchUnreadReactions({ chatId: chat.id }); } }); }); addActionHandler('loadWebPagePreview', async (global, actions, payload): Promise => { const { text, tabId = getCurrentTabId() } = payload; const webPagePreview = await callApi('fetchWebPagePreview', { text }); global = getGlobal(); global = updateTabState(global, { webPagePreview, }, tabId); setGlobal(global); }); addActionHandler('clearWebPagePreview', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; if (!selectTabState(global, tabId).webPagePreview) { return undefined; } return updateTabState(global, { webPagePreview: undefined, }, tabId); }); addActionHandler('sendPollVote', (global, actions, payload): ActionReturnType => { const { chatId, messageId, options } = payload; const chat = selectChat(global, chatId); if (chat) { void callApi('sendPollVote', { chat, messageId, options }); } }); addActionHandler('cancelPollVote', (global, actions, payload): ActionReturnType => { const { chatId, messageId } = payload; const chat = selectChat(global, chatId); if (chat) { void callApi('sendPollVote', { chat, messageId, options: [] }); } }); addActionHandler('closePoll', (global, actions, payload): ActionReturnType => { const { chatId, messageId } = payload; const chat = selectChat(global, chatId); const message = selectChatMessage(global, chatId, messageId); const poll = message && selectPollFromMessage(global, message); if (chat && poll) { void callApi('closePoll', { chat, messageId, poll }); } }); addActionHandler('loadPollOptionResults', async (global, actions, payload): Promise => { const { chat, messageId, option, offset, limit, shouldResetVoters, tabId = getCurrentTabId(), } = payload; const result = await callApi('loadPollOptionResults', { chat, messageId, option, offset, limit, }); if (!result) { return; } global = getGlobal(); const tabState = selectTabState(global, tabId); const { pollResults } = tabState; const { voters } = tabState.pollResults; global = updateTabState(global, { pollResults: { ...pollResults, voters: { ...voters, [option]: unique([ ...(!shouldResetVoters && voters?.[option] ? voters[option] : []), ...result.votes.map((vote) => vote.peerId), ]), }, offsets: { ...(pollResults.offsets ? pollResults.offsets : {}), [option]: result.nextOffset || '', }, }, }, tabId); setGlobal(global); }); addActionHandler('loadExtendedMedia', (global, actions, payload): ActionReturnType => { const { chatId, ids } = payload; const chat = selectChat(global, chatId); if (chat) { void callApi('fetchExtendedMedia', { chat, ids }); } }); addActionHandler('loadScheduledHistory', async (global, actions, payload): Promise => { if (selectIsCurrentUserFrozen(global)) return; const { chatId } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } const result = await callApi('fetchScheduledHistory', { chat }); if (!result) { return; } const { messages } = result; const byId = buildCollectionByKey(messages, 'id'); const ids = Object.keys(byId).map(Number).sort((a, b) => b - a); global = getGlobal(); global = updateScheduledMessages(global, chat.id, byId); global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'scheduledIds', ids); if (!ids.length) { global = updatePeerFullInfo(global, chat.id, { hasScheduledMessages: false }); } if (chat?.isForum) { const scheduledPerThread: Record = {}; messages.forEach((message) => { const threadId = selectThreadIdFromMessage(global, message); const scheduledInThread = scheduledPerThread[threadId] || []; scheduledInThread.push(message.id); scheduledPerThread[threadId] = scheduledInThread; }); Object.entries(scheduledPerThread).forEach(([threadId, scheduledIds]) => { global = replaceThreadParam(global, chat.id, Number(threadId), 'scheduledIds', scheduledIds); }); } setGlobal(global); }); addActionHandler('sendScheduledMessages', (global, actions, payload): ActionReturnType => { const { chatId, id, } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } void callApi('sendScheduledMessages', { chat, ids: [id], }); }); addActionHandler('rescheduleMessage', (global, actions, payload): ActionReturnType => { const { chatId, messageId, scheduledAt, } = payload; const chat = selectChat(global, chatId); const message = chat && selectScheduledMessage(global, chat.id, messageId); if (!chat || !message) { return; } void callApi('rescheduleMessage', { chat, message, scheduledAt, }); }); addActionHandler('transcribeAudio', async (global, actions, payload): Promise => { const { messageId, chatId } = payload; const chat = selectChat(global, chatId); if (!chat) return; global = updateChatMessage(global, chatId, messageId, { transcriptionId: '', }); setGlobal(global); const result = await callApi('transcribeAudio', { chat, messageId }); global = getGlobal(); global = updateChatMessage(global, chatId, messageId, { transcriptionId: result, isTranscriptionError: !result, }); setGlobal(global); }); addActionHandler('loadCustomEmojis', async (global, actions, payload): Promise => { const { ids, ignoreCache } = payload; const newCustomEmojiIds = ignoreCache ? ids : unique(ids.filter((documentId) => !global.customEmojis.byId[documentId])); const customEmoji = await callApi('fetchCustomEmoji', { documentId: newCustomEmojiIds, }); if (!customEmoji) return; global = getGlobal(); global = { ...global, customEmojis: { ...global.customEmojis, byId: { ...global.customEmojis.byId, ...buildCollectionByKey(customEmoji, 'id'), }, }, }; setGlobal(global); }); addActionHandler('forwardMessages', (global, actions, payload): ActionReturnType => { const { isSilent, scheduledAt, tabId = getCurrentTabId(), } = payload; const { toChatId } = selectTabState(global, tabId).forwardMessages; const toChat = toChatId ? selectChat(global, toChatId) : undefined; if (!toChat) return; executeForwardMessages(global, { chat: toChat, isSilent, scheduledAt }, tabId); }); async function executeForwardMessages(global: GlobalState, sendParams: SendMessageParams, tabId: number) { const { fromChatId, messageIds, toChatId, withMyScore, noAuthors, noCaptions, toThreadId = MAIN_THREAD_ID, } = selectTabState(global, tabId).forwardMessages; const { messagePriceInStars, isSilent, scheduledAt } = sendParams; const isCurrentUserPremium = selectIsCurrentUserPremium(global); const isToMainThread = toThreadId === MAIN_THREAD_ID; const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined; const toChat = toChatId ? selectChat(global, toChatId) : undefined; const messages = fromChatId && messageIds ? messageIds .sort((a, b) => a - b) .map((id) => selectChatMessage(global, fromChatId, id)).filter(Boolean) : undefined; if (!fromChat || !toChat || !messages || (toThreadId && !isToMainThread && !toChat.isForum)) { return undefined; } const sendAs = selectSendAs(global, toChatId!); const draft = selectDraft(global, toChatId!, toThreadId || MAIN_THREAD_ID); const lastMessageId = selectChatLastMessageId(global, toChat.id); const localMessages: SendMessageParams[] = []; const [realMessages, serviceMessages] = partition(messages, (m) => !isServiceNotificationMessage(m)); const forwardableRealMessages = realMessages.filter((message) => selectCanForwardMessage(global, message)); if (forwardableRealMessages.length) { const messageSlices = global.config?.maxForwardedCount ? splitMessagesForForwarding(forwardableRealMessages, global.config.maxForwardedCount) : [forwardableRealMessages]; for (const slice of messageSlices) { const forwardParams: ForwardMessagesParams = { fromChat, toChat, toThreadId, messages: slice, isSilent, scheduledAt, sendAs, withMyScore, noAuthors, noCaptions, isCurrentUserPremium, wasDrafted: Boolean(draft), lastMessageId, messagePriceInStars, }; if (!messagePriceInStars) { callApi('forwardMessages', forwardParams); } else { const forwardedLocalMessagesSlice = await callApi('forwardMessagesLocal', forwardParams); localMessages.push({ ...sendParams, forwardParams: { ...forwardParams, forwardedLocalMessagesSlice }, forwardedLocalMessagesSlice, }); } } } for (const message of serviceMessages) { const { text, entities } = message.content.text || {}; const { sticker } = message.content; const replyInfo = selectMessageReplyInfo(global, toChat.id, toThreadId); const params: SendMessageParams = { chat: toChat, replyInfo, text, entities, sticker, isSilent, scheduledAt, sendAs, lastMessageId, }; await sendMessageOrReduceLocal(global, params, localMessages); } global = getGlobal(); global = updateTabState(global, { forwardMessages: {}, isShareMessageModalShown: false, }, tabId); setGlobal(global); return localMessages; } async function loadViewportMessages( global: T, chat: ApiChat, threadId: ThreadId, offsetId: number | undefined, direction: LoadMoreDirection, isOutlying = false, isBudgetPreload = false, onLoaded?: NoneToVoidFunction, ...[tabId = getCurrentTabId()]: TabArgs ) { const chatId = chat.id; let addOffset: number | undefined; let sliceSize = MESSAGE_LIST_SLICE; switch (direction) { case LoadMoreDirection.Backwards: if (offsetId) { addOffset = -1; sliceSize += 1; } else { addOffset = undefined; } break; case LoadMoreDirection.Around: addOffset = -(Math.round(MESSAGE_LIST_SLICE / 2) + 1); break; case LoadMoreDirection.Forwards: addOffset = -(MESSAGE_LIST_SLICE + 1); if (offsetId) { sliceSize += 1; } break; } global = getGlobal(); const currentUserId = global.currentUserId!; const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const realChatId = isSavedDialog ? String(threadId) : chatId; const result = await callApi('fetchMessages', { chat: selectChat(global, realChatId)!, offsetId, addOffset, limit: sliceSize, threadId, isSavedDialog, }); if (!result) { return; } const { messages, count, } = result; global = getGlobal(); const localMessages = chatId === SERVICE_NOTIFICATIONS_USER_ID ? global.serviceNotifications.filter(({ isDeleted }) => !isDeleted).map(({ message }) => message) : []; const allMessages = ([] as ApiMessage[]).concat(messages, localMessages); const byId = buildCollectionByKey(allMessages, 'id'); const ids = Object.keys(byId).map(Number); if (threadId !== MAIN_THREAD_ID && !getIsSavedDialog(chatId, threadId, global.currentUserId)) { const threadFirstMessageId = selectFirstMessageId(global, chatId, threadId); if ((!ids[0] || threadFirstMessageId === ids[0]) && threadFirstMessageId !== threadId) { ids.unshift(Number(threadId)); } } global = addChatMessagesById(global, chatId, byId); global = isOutlying ? updateOutlyingLists(global, chatId, threadId, ids) : updateListedIds(global, chatId, threadId, ids); let listedIds = selectListedIds(global, chatId, threadId); const outlyingList = offsetId ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : undefined; if (isOutlying && listedIds && outlyingList) { if (!outlyingList.length || areSortedArraysIntersecting(listedIds, outlyingList)) { global = updateListedIds(global, chatId, threadId, outlyingList); listedIds = selectListedIds(global, chatId, threadId); global = removeOutlyingList(global, chatId, threadId, outlyingList); isOutlying = false; } } if (!isBudgetPreload) { const historyIds = isOutlying && outlyingList ? outlyingList : listedIds; if (historyIds) { const { newViewportIds } = getViewportSlice(historyIds, offsetId, direction); global = safeReplaceViewportIds(global, chatId, threadId, newViewportIds, tabId); } } if (count) { global = updateThreadInfo(global, chat.id, threadId, { messagesCount: count, }); } setGlobal(global); onLoaded?.(); } async function loadMessage( global: T, chat: ApiChat, messageId: number, replyOriginForId?: number, ) { const result = await callApi('fetchMessage', { chat, messageId }); if (!result) { return undefined; } if (result === MESSAGE_DELETED) { if (replyOriginForId) { global = getGlobal(); const replyMessage = selectChatMessage(global, chat.id, replyOriginForId); global = updateChatMessage(global, chat.id, replyOriginForId, { ...replyMessage, replyInfo: undefined, }); setGlobal(global); } return undefined; } global = getGlobal(); global = updateChatMessage(global, chat.id, messageId, result.message); setGlobal(global); return result.message; } function findClosestIndex(sourceIds: number[], offsetId: number) { if (offsetId < sourceIds[0]) { return 0; } if (offsetId > sourceIds[sourceIds.length - 1]) { return sourceIds.length - 1; } return sourceIds.findIndex((id, i) => ( id === offsetId || (id < offsetId && sourceIds[i + 1] > offsetId) )); } function getViewportSlice( sourceIds: number[], offsetId: number | undefined, direction: LoadMoreDirection, ) { const { length } = sourceIds; const index = offsetId ? findClosestIndex(sourceIds, offsetId) : -1; const isBackwards = direction === LoadMoreDirection.Backwards; const isAround = direction === LoadMoreDirection.Around; const indexForDirection = isBackwards ? index : (index + 1) || length; const sliceSize = isAround ? Math.round(MESSAGE_LIST_SLICE / 2) : MESSAGE_LIST_SLICE; const from = indexForDirection - sliceSize; const to = indexForDirection + sliceSize - 1; const newViewportIds = sourceIds.slice(Math.max(0, from), to + 1); let areSomeLocal; let areAllLocal; switch (direction) { case LoadMoreDirection.Backwards: areSomeLocal = indexForDirection >= 0; areAllLocal = from >= 0; break; case LoadMoreDirection.Forwards: areSomeLocal = indexForDirection < length; areAllLocal = to <= length - 1; break; case LoadMoreDirection.Around: default: areSomeLocal = newViewportIds.length > 0; areAllLocal = newViewportIds.length === MESSAGE_LIST_SLICE; break; } return { newViewportIds, areSomeLocal, areAllLocal }; } export async function getPeerStarsForMessage( global: T, peerId: string, ): Promise { const peer = selectPeer(global, peerId); if (!peer) return undefined; if (isApiPeerChat(peer)) { return peer.paidMessagesStars; } if (!peer?.paidMessagesStars) return undefined; const fullInfo = selectUserFullInfo(global, peer.id); if (fullInfo) { return fullInfo.paidMessagesStars; } const result = await callApi('fetchPaidMessagesStarsAmount', peer); return result; } async function sendMessageOrReduceLocal( global: T, sendParams: SendMessageParams, localMessages: SendMessageParams[], ) { if (!sendParams.messagePriceInStars) { sendMessage(global, sendParams); } else { const message = await callApi('sendMessageLocal', sendParams); if (message) { localMessages.push({ ...sendParams, localMessage: message, }); } } } async function sendMessage(global: T, params: SendMessageParams) { // @optimization if (params.replyInfo || IS_IOS) { await rafPromise(); } let currentMessageKey: MessageKey | undefined; const progressCallback = params.attachment ? (progress: number, messageKey: MessageKey) => { if (!uploadProgressCallbacks.has(messageKey)) { currentMessageKey = messageKey; uploadProgressCallbacks.set(messageKey, progressCallback!); } global = getGlobal(); global = updateUploadByMessageKey(global, messageKey, progress); setGlobal(global); } : undefined; await callApi('sendMessage', params, progressCallback); if (progressCallback && currentMessageKey) { global = getGlobal(); global = updateUploadByMessageKey(global, currentMessageKey, undefined); setGlobal(global); uploadProgressCallbacks.delete(currentMessageKey); } } async function sendMessagesWithNotification( global: T, sendParams: SendMessageParams[], ) { const chat = sendParams[0]?.chat; if (!chat || !sendParams.length) return; const starsForOneMessage = await getPeerStarsForMessage(global, chat.id); if (!starsForOneMessage) { getActions().sendMessages({ sendParams }); return; } const messageIdsForUndo = sendParams.reduce((ids, params) => { if (params.localMessage?.id) { ids.push(params.localMessage.id); } else if (params.forwardedLocalMessagesSlice?.localMessages) { const forwardedIds = Object.values(params.forwardedLocalMessagesSlice.localMessages) .map((forwardedMessage) => forwardedMessage.id) .filter(Boolean); ids.push(...forwardedIds); } return ids; }, [] as number[]); const localForwards = sendParams[0]?.forwardedLocalMessagesSlice?.localMessages; const firstMessage = sendParams[0]?.localMessage || (localForwards && Object.values(localForwards)[0]); if (!firstMessage) return; const messagesCount = messageIdsForUndo.length; const firstSendParam = sendParams[0]; let storySendMessage: RegularLangFnParameters | undefined; if (sendParams.length === 1 && firstSendParam.isStoryReply) { const { gif, sticker, isReaction } = firstSendParam; if (gif) { storySendMessage = { key: 'MessageSentPaidToastTitle', variables: { count: 1 }, options: { pluralValue: 1 } }; } else if (sticker) { storySendMessage = { key: 'StoryTooltipStickerSent' }; } else if (isReaction) { storySendMessage = { key: 'StoryTooltipReactionSent' }; } } const titleKey: RegularLangFnParameters = storySendMessage || { key: 'MessageSentPaidToastTitle', variables: { count: messagesCount }, options: { pluralValue: messagesCount }, }; getActions().sendMessages({ sendParams }); getActions().showNotification({ localId: getMessageKey(firstMessage), title: titleKey, message: { key: 'MessageSentPaidToastText', variables: { amount: formatStarsAsText(getTranslationFn(), starsForOneMessage * messagesCount) }, }, icon: 'star', shouldUseCustomIcon: true, type: 'paidMessage', }); } addActionHandler('sendMessages', async (global, actions, payload): Promise => { const { sendParams } = payload; await Promise.all(sendParams.map(async (params) => { if (params.forwardedLocalMessagesSlice && params.forwardParams) { await rafPromise(); await callApi('forwardApiMessages', params.forwardParams); } else { await sendMessage(global, params); } })); if (sendParams.length > 0 && sendParams[0].messagePriceInStars) actions.loadStarStatus(); }); addActionHandler('loadPinnedMessages', async (global, actions, payload): Promise => { const { chatId, threadId } = payload; const chat = selectChat(global, chatId); if (!chat || getIsSavedDialog(chatId, threadId, global.currentUserId)) { return; } const result = await callApi('fetchPinnedMessages', { chat, threadId }); if (!result) { return; } const { messages } = result; const byId = buildCollectionByKey(messages, 'id'); const ids = Object.keys(byId).map(Number).sort((a, b) => b - a); global = getGlobal(); global = addChatMessagesById(global, chat.id, byId); global = safeReplacePinnedIds(global, chat.id, threadId, ids); setGlobal(global); }); addActionHandler('loadSeenBy', async (global, actions, payload): Promise => { const { chatId, messageId } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } const result = await callApi('fetchSeenBy', { chat, messageId }); if (!result) { return; } global = getGlobal(); global = updateChatMessage(global, chatId, messageId, { seenByDates: result, }); setGlobal(global); }); addActionHandler('saveDefaultSendAs', (global, actions, payload): ActionReturnType => { const { chatId, sendAsId } = payload; const chat = selectChat(global, chatId); const sendAsChat = selectChat(global, sendAsId) || selectUser(global, sendAsId); if (!chat || !sendAsChat) { return undefined; } void callApi('saveDefaultSendAs', { sendAs: sendAsChat, chat }); return updateChatFullInfo(global, chatId, { sendAsId }); }); addActionHandler('loadSendAs', async (global, actions, payload): Promise => { const { chatId } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } const result = await callApi('fetchSendAs', { chat }); if (!result) { global = getGlobal(); global = updateChat(global, chatId, { sendAsPeerIds: [], }); setGlobal(global); return; } global = getGlobal(); global = updateChat(global, chatId, { sendAsPeerIds: result }); setGlobal(global); }); addActionHandler('loadSendPaidReactionsAs', async (global, actions, payload): Promise => { const { chatId } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } const result = await callApi('fetchSendAs', { chat, isForPaidReactions: true }); if (!result) { global = getGlobal(); global = updateChat(global, chatId, { sendPaidReactionsAsPeerIds: [], }); setGlobal(global); return; } global = getGlobal(); global = updateChat(global, chatId, { sendPaidReactionsAsPeerIds: result }); setGlobal(global); }); addActionHandler('loadSponsoredMessages', async (global, actions, payload): Promise => { if (selectIsCurrentUserFrozen(global)) return; const { peerId } = payload; const peer = selectPeer(global, peerId); if (!peer) { return; } if (isApiPeerUser(peer) && selectIsChatBotNotStarted(global, peer.id)) { return; } const result = await callApi('fetchSponsoredMessages', { peer }); if (!result) { return; } global = getGlobal(); global = updateSponsoredMessage(global, peerId, result.messages[0]); setGlobal(global); }); addActionHandler('viewSponsored', (global, actions, payload): ActionReturnType => { const { randomId } = payload; void callApi('viewSponsoredMessage', { random: randomId }); }); addActionHandler('clickSponsored', (global, actions, payload): ActionReturnType => { const { randomId, isMedia, isFullscreen } = payload; void callApi('clickSponsoredMessage', { random: randomId, isMedia, isFullscreen, }); }); addActionHandler('reportSponsored', async (global, actions, payload): Promise => { const { peerId, randomId, option = '', tabId = getCurrentTabId(), } = payload; const result = await callApi('reportSponsoredMessage', { randomId, option }); if (!result) return; if (result.type === 'premiumRequired') { actions.openPremiumModal({ initialSection: 'no_ads', tabId }); actions.closeReportAdModal({ tabId }); return; } if (result.type === 'reported' || result.type === 'hidden') { actions.showNotification({ message: oldTranslate(result.type === 'reported' ? 'AdReported' : 'AdHidden'), tabId, }); actions.closeReportAdModal({ tabId }); global = getGlobal(); if (peerId) { global = deleteSponsoredMessage(global, peerId); } else { global = updateGlobalSearch(global, { sponsoredPeer: undefined, }, tabId); } setGlobal(global); return; } if (result.type === 'selectOption') { global = getGlobal(); const oldSections = selectTabState(global, tabId).reportAdModal?.sections; const selectedOption = oldSections?.[oldSections.length - 1]?.options.find((o) => o.option === option); const newSection = { title: result.title, options: result.options, subtitle: selectedOption?.text, }; global = updateTabState(global, { reportAdModal: { chatId: peerId, randomId, sections: oldSections ? [...oldSections, newSection] : [newSection], }, }, tabId); setGlobal(global); } }); addActionHandler('hideSponsored', async (global, actions, payload): Promise => { const { tabId = getCurrentTabId() } = payload || {}; const isCurrentUserPremium = selectIsCurrentUserPremium(global); if (!isCurrentUserPremium) { actions.openPremiumModal({ initialSection: 'no_ads', tabId }); return; } const result = await callApi('toggleSponsoredMessages', { enabled: false }); if (!result) return; global = getGlobal(); global = updateUserFullInfo(global, global.currentUserId!, { areAdsEnabled: false, }); setGlobal(global); actions.showNotification({ message: oldTranslate('AdHidden'), tabId, }); }); addActionHandler('fetchUnreadMentions', async (global, actions, payload): Promise => { const { chatId, offsetId } = payload; await fetchUnreadMentions(global, chatId, offsetId); }); async function fetchUnreadMentions(global: T, chatId: string, offsetId?: number) { const chat = selectChat(global, chatId); if (!chat) return; const result = await callApi('fetchUnreadMentions', { chat, offsetId }); if (!result) return; const { messages } = result; const byId = buildCollectionByKey(messages, 'id'); const ids = Object.keys(byId).map(Number); global = getGlobal(); global = addChatMessagesById(global, chat.id, byId); global = addUnreadMentions(global, chatId, chat, ids); setGlobal(global); } addActionHandler('markMentionsRead', (global, actions, payload): ActionReturnType => { const { chatId, messageIds, tabId = getCurrentTabId() } = payload; const chat = selectChat(global, chatId); if (!chat) return; global = removeUnreadMentions(global, chatId, chat, messageIds, true); setGlobal(global); actions.markMessagesRead({ messageIds, tabId }); }); addActionHandler('focusNextMention', async (global, actions, payload): Promise => { const { tabId = getCurrentTabId() } = payload || {}; let chat = selectCurrentChat(global, tabId); if (!chat) return; if (!chat.unreadMentions) { await fetchUnreadMentions(global, chat.id); global = getGlobal(); const previousChatId = chat.id; chat = selectCurrentChat(global, tabId); if (!chat?.unreadMentions || previousChatId !== chat.id) return; } actions.focusMessage({ chatId: chat.id, messageId: chat.unreadMentions[0], tabId }); }); addActionHandler('readAllMentions', (global, actions, payload): ActionReturnType => { const { chatId, threadId = MAIN_THREAD_ID } = payload; const chat = selectChat(global, chatId); if (!chat) return undefined; callApi('readAllMentions', { chat, threadId: threadId === MAIN_THREAD_ID ? undefined : threadId }); if (threadId === MAIN_THREAD_ID) { return updateChat(global, chat.id, { unreadMentionsCount: undefined, unreadMentions: undefined, }); } // TODO[Forums]: Support mentions in threads return undefined; }); addActionHandler('openUrl', (global, actions, payload): ActionReturnType => { const { url, shouldSkipModal, ignoreDeepLinks, tabId = getCurrentTabId(), } = payload; const urlWithProtocol = ensureProtocol(url); const parsedUrl = new URL(urlWithProtocol); const isMixedScript = isMixedScriptUrl(urlWithProtocol); if (!ignoreDeepLinks && isDeepLink(urlWithProtocol)) { actions.closeStoryViewer({ tabId }); actions.closePaymentModal({ tabId }); actions.openTelegramLink({ url, tabId }); return; } const { appConfig, config } = global; if (appConfig) { if (config?.autologinToken && appConfig.autologinDomains.includes(parsedUrl.hostname)) { parsedUrl.searchParams.set(AUTOLOGIN_TOKEN_KEY, config.autologinToken); window.open(parsedUrl.href, '_blank', 'noopener'); return; } if (appConfig.urlAuthDomains.includes(parsedUrl.hostname)) { actions.closeStoryViewer({ tabId }); actions.requestLinkUrlAuth({ url, tabId }); return; } } const shouldDisplayModal = !urlWithProtocol.match(RE_TELEGRAM_LINK) && !shouldSkipModal; if (shouldDisplayModal) { actions.toggleSafeLinkModal({ url: isMixedScript ? parsedUrl.toString() : urlWithProtocol, tabId }); } else { window.open(parsedUrl, '_blank', 'noopener'); } }); async function checkIfVoiceMessagesAllowed( global: T, user: ApiUser, chatId: string, ): Promise { let fullInfo = selectUserFullInfo(global, chatId); if (!fullInfo) { const { accessHash } = user; const result = await callApi('fetchFullUser', { id: chatId, accessHash }); fullInfo = result?.fullInfo; } return Boolean(!fullInfo?.noVoiceMessages); } function moveReplyToNewDraft( global: T, threadId: ThreadId, replyInfo: ApiInputMessageReplyInfo, toChatId: string, ) { const currentDraft = selectDraft(global, toChatId, threadId); if (!replyInfo.replyToMsgId) return; const newDraft: ApiDraft = { ...currentDraft, replyInfo, }; saveDraft({ global, chatId: toChatId, threadId, draft: newDraft, isLocalOnly: true, noLocalTimeUpdate: true, }); } addActionHandler('openChatOrTopicWithReplyInDraft', (global, actions, payload): ActionReturnType => { const { chatId: toChatId, topicId, tabId = getCurrentTabId() } = payload; global = getGlobal(); const tabState = selectTabState(global, tabId); const replyingInfo = tabState.replyingMessage; global = updateTabState(global, { isShareMessageModalShown: false, replyingMessage: {}, }, tabId); setGlobal(global); global = getGlobal(); const currentChat = selectCurrentChat(global, tabId); const currentThreadId = selectCurrentMessageList(global, tabId)?.threadId; if (!currentChat || !currentThreadId) return; const threadId = topicId || MAIN_THREAD_ID; const currentChatId = currentChat.id; const newReplyInfo = { type: 'message', replyToMsgId: replyingInfo.messageId, replyToTopId: replyingInfo.toThreadId, replyToPeerId: currentChatId, monoforumPeerId: replyingInfo.toThreadId, quoteText: replyingInfo.quoteText, quoteOffset: replyingInfo.quoteOffset, } as ApiInputMessageReplyInfo; const currentReplyInfo = replyingInfo.messageId ? newReplyInfo : selectDraft(global, currentChatId, currentThreadId)?.replyInfo; if (!currentReplyInfo) return; if (!selectReplyCanBeSentToChat(global, toChatId, currentChatId, currentReplyInfo)) { actions.showNotification({ message: oldTranslate('Chat.SendNotAllowedText'), tabId }); return; } if (!currentReplyInfo.replyToPeerId && toChatId === currentChat.id) return; const getPeerId = () => { if (!currentReplyInfo?.replyToPeerId) return currentChatId; return currentReplyInfo.replyToPeerId === toChatId ? undefined : currentReplyInfo.replyToPeerId; }; const replyToPeerId = getPeerId(); const newReply: ApiInputMessageReplyInfo = { ...currentReplyInfo, replyToPeerId, type: 'message', }; moveReplyToNewDraft(global, threadId, newReply, toChatId); actions.openThread({ chatId: toChatId, threadId, tabId }); actions.closeMediaViewer({ tabId }); actions.exitMessageSelectMode({ tabId }); actions.clearDraft({ chatId: currentChatId, threadId: currentThreadId }); }); addActionHandler('setForwardChatOrTopic', async (global, actions, payload): Promise => { const { chatId, topicId, tabId = getCurrentTabId() } = payload; const user = selectUser(global, chatId); const isSelectForwardsContainVoiceMessages = selectForwardsContainVoiceMessages(global, tabId); if (isSelectForwardsContainVoiceMessages && user && !await checkIfVoiceMessagesAllowed(global, user, chatId)) { actions.showDialog({ data: { message: oldTranslate('VoiceMessagesRestrictedByPrivacy', getUserFullName(user)), }, tabId, }); return; } global = getGlobal(); if (!selectForwardsCanBeSentToChat(global, chatId, tabId)) { actions.showAllowedMessageTypesNotification({ chatId, tabId }); return; } global = updateTabState(global, { forwardMessages: { ...selectTabState(global, tabId).forwardMessages, toChatId: chatId, toThreadId: topicId, }, isShareMessageModalShown: false, }, tabId); setGlobal(global); actions.openThread({ chatId, threadId: topicId || MAIN_THREAD_ID, tabId }); actions.closeMediaViewer({ tabId }); actions.exitMessageSelectMode({ tabId }); }); addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; global = updateTabState(global, { forwardMessages: { ...selectTabState(global, tabId).forwardMessages, toChatId: global.currentUserId, }, }, tabId); setGlobal(global); actions.exitMessageSelectMode({ tabId }); actions.forwardMessages({ isSilent: true, tabId }); }); addActionHandler('forwardStory', (global, actions, payload): ActionReturnType => { const { toChatId, tabId = getCurrentTabId() } = payload || {}; const { fromChatId, storyId } = selectTabState(global, tabId).forwardMessages; const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined; const toChat = toChatId ? selectChat(global, toChatId) : undefined; const story = fromChatId && storyId ? selectPeerStory(global, fromChatId, storyId) : undefined; if (!fromChat || !toChat || !story || 'isDeleted' in story) { return; } const lastMessageId = selectChatLastMessageId(global, toChatId); const { text, entities } = (story as ApiStory).content.text || {}; void sendMessage(global, { chat: toChat, text, entities, story, lastMessageId, }); global = getGlobal(); global = updateTabState(global, { forwardMessages: {}, isShareMessageModalShown: false, }, tabId); setGlobal(global); }); addActionHandler('requestMessageTranslation', (global, actions, payload): ActionReturnType => { const { chatId, id, toLanguageCode = selectTranslationLanguage(global), tabId = getCurrentTabId(), } = payload; global = updateRequestedMessageTranslation(global, chatId, id, toLanguageCode, tabId); global = replaceSettings(global, { translationLanguage: toLanguageCode, }); return global; }); addActionHandler('showOriginalMessage', (global, actions, payload): ActionReturnType => { const { chatId, id, tabId = getCurrentTabId(), } = payload; global = removeRequestedMessageTranslation(global, chatId, id, tabId); return global; }); addActionHandler('markMessagesTranslationPending', (global, actions, payload): ActionReturnType => { const { chatId, messageIds, toLanguageCode = selectLanguageCode(global), } = payload; messageIds.forEach((id) => { global = updateMessageTranslation(global, chatId, id, toLanguageCode, { isPending: true, }); }); return global; }); addActionHandler('translateMessages', (global, actions, payload): ActionReturnType => { const { chatId, messageIds, toLanguageCode = selectLanguageCode(global), } = payload; const chat = selectChat(global, chatId); if (!chat) return undefined; actions.markMessagesTranslationPending({ chatId, messageIds, toLanguageCode }); callApi('translateText', { chat, messageIds, toLanguageCode, }); return global; }); // https://github.com/telegramdesktop/tdesktop/blob/11906297d82b6ff57b277da5251d2e6eb3d8b6d0/Telegram/SourceFiles/api/api_views.cpp#L22 const SEND_VIEWS_TIMEOUT = 1000; let viewsIncrementTimeout: number | undefined; let idsToIncrementViews: Record> = {}; function incrementViews() { if (viewsIncrementTimeout) { clearTimeout(viewsIncrementTimeout); viewsIncrementTimeout = undefined; } const { loadMessageViews } = getActions(); Object.entries(idsToIncrementViews).forEach(([chatId, ids]) => { loadMessageViews({ chatId, ids: Array.from(ids), shouldIncrement: true }); }); idsToIncrementViews = {}; } addActionHandler('scheduleForViewsIncrement', (global, actions, payload): ActionReturnType => { const { ids, chatId } = payload; if (!viewsIncrementTimeout) { setTimeout(incrementViews, SEND_VIEWS_TIMEOUT); } if (!idsToIncrementViews[chatId]) { idsToIncrementViews[chatId] = new Set(); } ids.forEach((id) => { idsToIncrementViews[chatId].add(id); }); }); addActionHandler('loadMessageViews', async (global, actions, payload): Promise => { const { chatId, ids, shouldIncrement } = payload; if (selectIsCurrentUserFrozen(global)) return; const chat = selectChat(global, chatId); if (!chat) return; const result = await callApi('fetchMessageViews', { chat, ids, shouldIncrement, }); if (!result) return; global = getGlobal(); result.viewsInfo.forEach((update) => { global = updateChatMessage(global, chatId, update.id, { viewsCount: update.views, forwardsCount: update.forwards, }, true); if (update.threadInfo) { global = updateThreadInfo(global, chatId, update.id, update.threadInfo); } }); setGlobal(global); }); addActionHandler('loadFactChecks', async (global, actions, payload): Promise => { const { chatId, ids } = payload; const chat = selectChat(global, chatId); if (!chat) return; const result = await callApi('fetchFactChecks', { chat, ids, }); if (!result) return; global = getGlobal(); result.forEach((factCheck, i) => { global = updateChatMessage(global, chatId, ids[i], { factCheck, }); }); setGlobal(global); }); addActionHandler('loadPaidReactionPrivacy', (): ActionReturnType => { callApi('fetchPaidReactionPrivacy'); return undefined; }); addActionHandler('loadOutboxReadDate', async (global, actions, payload): Promise => { const { chatId, messageId } = payload; const chat = selectChat(global, chatId); if (!chat) return; try { const result = await callApi('fetchOutboxReadDate', { chat, messageId }); if (result?.date) { global = getGlobal(); global = updateChatMessage(global, chatId, messageId, { readDate: result.date }); setGlobal(global); } } catch (error) { const { message } = error as ApiError; if (message === 'USER_PRIVACY_RESTRICTED' || message === 'YOUR_PRIVACY_RESTRICTED') { global = getGlobal(); const user = selectUser(global, chatId); if (!user) return; const userStatus = selectUserStatus(global, chatId); if (!userStatus) return; const updateStatus = message === 'USER_PRIVACY_RESTRICTED' ? { isReadDateRestricted: true } : { isReadDateRestrictedByMe: true }; global = replaceUserStatuses(global, { [chatId]: { ...userStatus, ...updateStatus }, }); // Need to reset `readDate` to `undefined` after click on "Show my Read Time" button global = updateChatMessage(global, chatId, messageId, { readDate: undefined }); setGlobal(global); } } }); addActionHandler('loadQuickReplies', async (global): Promise => { const result = await callApi('fetchQuickReplies'); if (!result) return; global = getGlobal(); global = updateQuickReplyMessages(global, buildCollectionByKey(result.messages, 'id')); global = updateQuickReplies(global, result.quickReplies); setGlobal(global); }); addActionHandler('sendQuickReply', (global, actions, payload): ActionReturnType => { const { chatId, quickReplyId } = payload; const chat = selectChat(global, chatId); if (!chat) return global; callApi('sendQuickReply', { chat, shortcutId: quickReplyId, }); return global; }); addActionHandler('copyMessageLink', async (global, actions, payload): Promise => { const { chatId, messageId, shouldIncludeThread, shouldIncludeGrouped, tabId = getCurrentTabId(), } = payload; const chat = selectChat(global, chatId); if (!chat) { actions.showNotification({ message: oldTranslate('ErrorOccurred'), tabId, }); return; } const showErrorOccurredNotification = () => actions.showNotification({ message: oldTranslate('ErrorOccurred'), tabId, }); if (!isChatChannel(chat) && !isChatSuperGroup(chat)) { showErrorOccurredNotification(); return; } const showLinkCopiedNotification = () => actions.showNotification({ message: oldTranslate('LinkCopied'), tabId, }); const callApiExportMessageLinkPromise = callApi('exportMessageLink', { chat, id: messageId, shouldIncludeThread, shouldIncludeGrouped, }); await copyTextToClipboardFromPromise( callApiExportMessageLinkPromise, showLinkCopiedNotification, showErrorOccurredNotification, ); }); const MESSAGES_TO_REPORT_DELIVERY = new Map(); let reportDeliveryTimeout: number | undefined; addActionHandler('reportMessageDelivery', (global, actions, payload): ActionReturnType => { const { chatId, messageId } = payload; const currentIds = MESSAGES_TO_REPORT_DELIVERY.get(chatId) || []; currentIds.push(messageId); MESSAGES_TO_REPORT_DELIVERY.set(chatId, currentIds); if (!reportDeliveryTimeout) { // Slightly unsafe in the multitab environment, but there is no better way to do it now. // Not critical if user manages to close the tab in a show window before the report is sent. reportDeliveryTimeout = window.setTimeout(() => { reportDeliveryTimeout = undefined; MESSAGES_TO_REPORT_DELIVERY.forEach((messageIds, cId) => { const chat = selectChat(global, cId); if (!chat) return; callApi('reportMessagesDelivery', { chat, messageIds }); }); MESSAGES_TO_REPORT_DELIVERY.clear(); }, 500); } }); addActionHandler('openPreparedInlineMessageModal', async (global, actions, payload): Promise => { const { botId, messageId, webAppKey, tabId = getCurrentTabId(), } = payload; const bot = selectUser(global, botId); if (!bot) return; const result = await callApi('fetchPreparedInlineMessage', { bot, id: messageId, }); if (!result) { actions.sendWebAppEvent({ webAppKey, event: { eventType: 'prepared_message_failed', eventData: { error: 'MESSAGE_EXPIRED' }, }, tabId, }); return; } global = getGlobal(); global = updateTabState(global, { preparedMessageModal: { message: result, webAppKey, botId, }, }, tabId); setGlobal(global); }); addActionHandler('openSharePreparedMessageModal', (global, actions, payload): ActionReturnType => { const { webAppKey, message, tabId = getCurrentTabId(), } = payload; const supportedFilters = message.peerTypes?.filter((type): type is ApiChatType => type !== 'self'); global = getGlobal(); global = updateTabState(global, { sharePreparedMessageModal: { webAppKey, filter: supportedFilters, message, }, }, tabId); setGlobal(global); }); function countSortedIds(ids: number[], from: number, to: number) { // If ids are outside viewport, we cannot get correct count if (ids.length === 0 || from < ids[0] || to > ids[ids.length - 1]) return undefined; let count = 0; for (let i = 0, l = ids.length; i < l; i++) { if (ids[i] >= from && ids[i] <= to) { count++; } if (ids[i] >= to) { break; } } return count; } function splitAttachmentsByType(attachments: ApiAttachment[]) { return attachments.reduce((acc, attachment, index, arr) => { if (index === 0) { acc.push([attachment]); return acc; } const type = getAttachmentType(attachment); const previousType = getAttachmentType(arr[index - 1]); if (type === previousType) { acc[acc.length - 1].push(attachment); } else { acc.push([attachment]); } return acc; }, [] as ApiAttachment[][]); } function getAttachmentType(attachment: ApiAttachment) { const { shouldSendAsFile, mimeType, } = attachment; if (SUPPORTED_AUDIO_CONTENT_TYPES.has(mimeType)) return 'audio'; if (shouldSendAsFile) return 'file'; if (mimeType === GIF_MIME_TYPE) return 'gif'; if (SUPPORTED_PHOTO_CONTENT_TYPES.has(mimeType) || SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) return 'media'; if (attachment.voice) return 'voice'; return 'file'; }