Alexander Zinchuk 9bfd2d569c Layer 205: Introduce Checklists (#6010)
Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
2025-07-04 14:13:43 +02:00

2726 lines
79 KiB
TypeScript

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 { getServerTime } from '../../../util/serverTime';
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 { updateWithLocalMedia } from '../apiUpdaters/messages';
import { deleteMessages } from '../apiUpdaters/messages';
const AUTOLOGIN_TOKEN_KEY = 'autologin_token';
const uploadProgressCallbacks = new Map<MessageKey, ApiOnProgress>();
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<T extends GlobalState>(
global: T,
actions: RequiredGlobalActions,
areAllLocal: boolean, isOutlying: boolean, isBudgetPreload: boolean,
chat: ApiChat, threadId: ThreadId, direction: LoadMoreDirection, offsetId?: number,
onLoaded?: NoneToVoidFunction,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
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<void> => {
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<void> => {
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<void> => {
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('editTodo', (global, actions, payload): ActionReturnType => {
const {
chatId, todo, messageId,
} = payload;
const chat = selectChat(global, chatId);
const message = selectChatMessage(global, chatId, messageId);
if (!chat || !message) {
return;
}
callApi('editTodo', {
chat,
message,
todo,
});
});
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<T extends GlobalState>({
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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('toggleTodoCompleted', (global, actions, payload): ActionReturnType => {
const { chatId, messageId, completedIds, incompletedIds } = payload;
const chat = selectChat(global, chatId);
const message = selectChatMessage(global, chatId, messageId);
const currentUserId = global.currentUserId;
const currentTodo = message?.content.todo;
if (!currentTodo || !currentUserId || !chat) {
return;
}
const currentCompletions = currentTodo.completions || [];
const currentCompletionIds = currentCompletions.map((c) => c.itemId);
const newCompletions = [...currentCompletions];
const now = getServerTime();
completedIds.forEach((itemId) => {
if (!currentCompletionIds.includes(itemId)) {
newCompletions.push({
itemId,
completedBy: currentUserId,
completedAt: now,
});
}
});
const finalCompletions = newCompletions.filter((c) => !incompletedIds.includes(c.itemId));
const newContent = {
...message.content,
todo: {
...currentTodo,
completions: finalCompletions,
},
};
const messageUpdate: Partial<ApiMessage> = {
...message,
content: newContent,
};
global = updateWithLocalMedia(global, chatId, message.id, messageUpdate);
setGlobal(global);
callApi('toggleTodoCompleted', { chat, messageId: message.id, completedIds, incompletedIds });
});
addActionHandler('appendTodoList', (global, actions, payload): ActionReturnType => {
const {
chatId, items, messageId,
} = payload;
const chat = selectChat(global, chatId);
const message = selectChatMessage(global, chatId, messageId);
if (!chat || !message) {
return;
}
callApi('appendTodoList', {
chat,
message,
items,
});
});
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<void> => {
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<void> => {
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<ThreadId, number[]> = {};
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<void> => {
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<void> => {
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<T extends GlobalState>(
global: T,
chat: ApiChat,
threadId: ThreadId,
offsetId: number | undefined,
direction: LoadMoreDirection,
isOutlying = false,
isBudgetPreload = false,
onLoaded?: NoneToVoidFunction,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
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<T extends GlobalState>(
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<T extends GlobalState>(
global: T,
peerId: string,
): Promise<number | undefined> {
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<T extends GlobalState>(
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<T extends GlobalState>(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<T extends GlobalState>(
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
const { chatId, offsetId } = payload;
await fetchUnreadMentions(global, chatId, offsetId);
});
async function fetchUnreadMentions<T extends GlobalState>(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<void> => {
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<T extends GlobalState>(
global: T,
user: ApiUser,
chatId: string,
): Promise<boolean> {
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<T extends GlobalState>(
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<void> => {
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<string, Set<number>> = {};
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<string, number[]>();
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<void> => {
const {
botId, messageId, webAppKey, tabId = getCurrentTabId(),
} = payload;
const bot = selectUser(global, botId);
if (!bot) return;
const result = await callApi('fetchPreparedInlineMessage', {
bot,
id: messageId,
});
if (!result) {
actions.sendWebAppEvent({
webAppKey,
event: {
eventType: 'prepared_message_failed',
eventData: { error: 'MESSAGE_EXPIRED' },
},
tabId,
});
return;
}
global = getGlobal();
global = updateTabState(global, {
preparedMessageModal: {
message: result,
webAppKey,
botId,
},
}, tabId);
setGlobal(global);
});
addActionHandler('openSharePreparedMessageModal', (global, actions, payload): ActionReturnType => {
const {
webAppKey, message, tabId = getCurrentTabId(),
} = payload;
const supportedFilters = message.peerTypes?.filter((type): type is ApiChatType => type !== 'self');
global = getGlobal();
global = updateTabState(global, {
sharePreparedMessageModal: {
webAppKey,
filter: supportedFilters,
message,
},
}, tabId);
setGlobal(global);
});
function countSortedIds(ids: number[], from: number, to: number) {
// If ids are outside viewport, we cannot get correct count
if (ids.length === 0 || from < ids[0] || to > ids[ids.length - 1]) return undefined;
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';
}