2025-03-30 15:46:13 +02:00

1305 lines
39 KiB
TypeScript

import type {
ApiChat, ApiMediaExtendedPreview, ApiMessage, ApiReactions,
MediaContent,
} from '../../../api/types';
import type { ActiveEmojiInteraction, ThreadId } from '../../../types';
import type { RequiredGlobalActions } from '../../index';
import type {
ActionReturnType, GlobalState, RequiredGlobalState,
} from '../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { areDeepEqual } from '../../../util/areDeepEqual';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import {
buildCollectionByKey, omit, pickTruthy, unique,
} from '../../../util/iteratees';
import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey';
import { notifyAboutMessage } from '../../../util/notifications';
import { onTickEnd } from '../../../util/schedulers';
import { getServerTime } from '../../../util/serverTime';
import {
addPaidReaction,
checkIfHasUnreadReactions, getIsSavedDialog, getMessageContent, getMessageText, isActionMessage,
isMessageLocal, isUserId,
} from '../../helpers';
import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies';
import {
addActionHandler,
getGlobal,
setGlobal,
} from '../../index';
import {
addMessages,
addViewportId,
clearMessageTranslation,
deleteChatMessages,
deleteChatScheduledMessages,
deletePeerPhoto,
deleteQuickReply,
deleteQuickReplyMessages,
deleteTopic,
removeChatFromChatLists,
replaceThreadParam,
updateChat,
updateChatLastMessageId,
updateChatMediaLoadingState,
updateChatMessage,
updateListedIds,
updateMessageTranslations,
updatePeerFullInfo,
updatePoll,
updatePollVote,
updateQuickReplies,
updateQuickReplyMessage,
updateScheduledMessage,
updateThreadInfo,
updateThreadInfos,
updateThreadUnreadFromForwardedMessage,
updateTopic,
} from '../../reducers';
import { updateUnreadReactions } from '../../reducers/reactions';
import { updateTabState } from '../../reducers/tabs';
import {
selectCanAnimateSnapEffect,
selectChat,
selectChatLastMessageId,
selectChatMessage,
selectChatMessages,
selectChatScheduledMessages,
selectCommonBoxChatId,
selectCurrentMessageList,
selectFirstUnreadId,
selectIsChatListed,
selectIsChatWithSelf,
selectIsMessageInCurrentMessageList,
selectIsServiceChatReady,
selectIsViewportNewest,
selectListedIds,
selectPerformanceSettingsValue,
selectPinnedIds,
selectSavedDialogIdFromMessage,
selectScheduledIds,
selectScheduledMessage,
selectTabState,
selectThreadByMessage,
selectThreadIdFromMessage,
selectThreadInfo,
selectTopic,
selectTopicFromMessage,
selectViewportIds,
} from '../../selectors';
const ANIMATION_DELAY = 350;
const SNAP_ANIMATION_DELAY = 1000;
const VIDEO_PROCESSING_NOTIFICATION_DELAY = 1000;
let lastVideoProcessingNotificationTime = 0;
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
switch (update['@type']) {
case 'newMessage': {
const {
chatId, id, message, shouldForceReply, wasDrafted, poll,
} = update;
global = updateWithLocalMedia(global, chatId, id, message);
global = updateListedAndViewportIds(global, actions, message as ApiMessage);
const newMessage = selectChatMessage(global, chatId, id)!;
const replyInfo = getMessageReplyInfo(newMessage);
const storyReplyInfo = getStoryReplyInfo(newMessage);
const chat = selectChat(global, chatId);
if (chat?.isForum
&& replyInfo?.isForumTopic
&& !selectTopicFromMessage(global, newMessage)
&& replyInfo.replyToMsgId) {
actions.loadTopicById({ chatId, topicId: replyInfo.replyToMsgId });
}
const isLocal = isMessageLocal(message as ApiMessage);
Object.values(global.byTabId).forEach(({ id: tabId }) => {
// Force update for last message on drafted messages to prevent flickering
if (isLocal && wasDrafted) {
global = updateChatLastMessage(global, chatId, newMessage);
}
const threadId = selectThreadIdFromMessage(global, newMessage);
global = updateChatMediaLoadingState(global, newMessage, chatId, threadId, tabId);
if (selectIsMessageInCurrentMessageList(global, chatId, message as ApiMessage, tabId)) {
if (isLocal && message.isOutgoing && !(message.content?.action) && !storyReplyInfo?.storyId
&& !message.content?.storyData) {
const currentMessageList = selectCurrentMessageList(global, tabId);
if (currentMessageList) {
// We do not use `actions.focusLastMessage` as it may be set with a delay (see below)
actions.focusMessage({
chatId,
threadId: currentMessageList.threadId,
messageId: message.id!,
noHighlight: true,
isResizingContainer: true,
tabId,
});
}
}
// @perf Wait until scroll animation finishes or simply rely on delivery status update
// (which is itself delayed)
if (!isLocal) {
setTimeout(() => {
global = getGlobal();
if (shouldForceReply) {
actions.updateDraftReplyInfo({
replyToMsgId: id,
tabId,
});
}
global = updateChatLastMessage(global, chatId, newMessage);
setGlobal(global);
}, ANIMATION_DELAY);
}
} else {
global = updateChatLastMessage(global, chatId, newMessage);
}
});
if (poll) {
global = updatePoll(global, poll.id, poll);
}
if (message.reportDeliveryUntilDate && message.reportDeliveryUntilDate > getServerTime()) {
actions.reportMessageDelivery({ chatId, messageId: id });
}
setGlobal(global);
// Reload dialogs if chat is not present in the list
if (!isLocal && !chat?.isNotJoined && !selectIsChatListed(global, chatId)) {
actions.loadTopChats();
}
if (!isLocal && selectIsChatWithSelf(global, chatId)) {
const savedDialogId = selectSavedDialogIdFromMessage(global, newMessage);
if (savedDialogId && !selectIsChatListed(global, savedDialogId, 'saved')) {
actions.requestSavedDialogUpdate({ chatId: savedDialogId });
}
}
break;
}
case 'updateChatLastMessage': {
const { id, lastMessage } = update;
global = updateChatLastMessage(global, id, lastMessage, true);
global = addMessages(global, [lastMessage]);
setGlobal(global);
break;
}
case 'updateStartEmojiInteraction': {
Object.values(global.byTabId).forEach(({ id: tabId }) => {
const { chatId: currentChatId } = selectCurrentMessageList(global, tabId) || {};
if (currentChatId !== update.id) return;
const message = selectChatMessage(global, currentChatId, update.messageId);
if (!message) return;
// Workaround for a weird behavior when interaction is received after watching reaction
if (getMessageText(message)?.text !== update.emoji) return;
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
activeEmojiInteractions: [...(tabState.activeEmojiInteractions || []), {
id: Math.random(),
animatedEffect: update.emoji,
messageId: update.messageId,
} as ActiveEmojiInteraction],
}, tabId);
});
setGlobal(global);
break;
}
case 'newScheduledMessage': {
const {
chatId, id, message, poll,
} = update;
global = updateWithLocalMedia(global, chatId, id, message, true);
const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || [];
global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', unique([...scheduledIds, id]));
const threadId = selectThreadIdFromMessage(global, message);
if (threadId !== MAIN_THREAD_ID) {
const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || [];
global = replaceThreadParam(global, chatId, threadId, 'scheduledIds', unique([...threadScheduledIds, id]));
}
if (poll) {
global = updatePoll(global, poll.id, poll);
}
global = updatePeerFullInfo(global, chatId, {
hasScheduledMessages: true,
});
setGlobal(global);
break;
}
case 'updateMessage': {
const {
chatId, id, message, poll,
} = update;
const currentMessage = selectChatMessage(global, chatId, id);
const chat = selectChat(global, chatId);
global = updateWithLocalMedia(global, chatId, id, message);
const newMessage = selectChatMessage(global, chatId, id)!;
if (message.reactions && chat) {
global = updateReactions(
global, actions, chatId, id, message.reactions, chat, newMessage.isOutgoing, currentMessage,
);
}
if (message.content?.text?.text !== currentMessage?.content?.text?.text) {
global = clearMessageTranslation(global, chatId, id);
}
if (poll) {
global = updatePoll(global, poll.id, poll);
}
setGlobal(global);
break;
}
case 'updateScheduledMessage': {
const {
chatId, id, message, poll,
} = update;
const currentMessage = selectScheduledMessage(global, chatId, id);
if (!currentMessage) {
return;
}
global = updateWithLocalMedia(global, chatId, id, message, true);
const ids = Object.keys(selectChatScheduledMessages(global, chatId) || {}).map(Number).sort((a, b) => b - a);
global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', ids);
const threadId = selectThreadIdFromMessage(global, currentMessage);
if (threadId !== MAIN_THREAD_ID) {
const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || [];
global = replaceThreadParam(global, chatId, threadId, 'scheduledIds', threadScheduledIds.sort((a, b) => b - a));
}
if (poll) {
global = updatePoll(global, poll.id, poll);
}
setGlobal(global);
break;
}
case 'updateQuickReplyMessage': {
const { id, message, poll } = update;
global = updateQuickReplyMessage(global, id, message);
if (poll) {
global = updatePoll(global, poll.id, poll);
}
setGlobal(global);
break;
}
case 'deleteQuickReplyMessages': {
const { messageIds } = update;
global = deleteQuickReplyMessages(global, messageIds);
setGlobal(global);
break;
}
case 'updateQuickReplies': {
const { quickReplies } = update;
const byId = buildCollectionByKey(quickReplies, 'id');
global = updateQuickReplies(global, byId);
setGlobal(global);
break;
}
case 'deleteQuickReply': {
global = deleteQuickReply(global, update.quickReplyId);
setGlobal(global);
break;
}
case 'updateVideoProcessingPending': {
const {
chatId, localId, newScheduledMessageId,
} = update;
global = deleteChatMessages(global, chatId, [localId]);
global = updatePeerFullInfo(global, chatId, {
hasScheduledMessages: true,
});
setGlobal(global);
Object.values(global.byTabId).forEach(({ id: tabId }) => {
const currentMessageList = selectCurrentMessageList(global, tabId);
if (currentMessageList?.chatId !== chatId) return;
const now = Date.now();
if (now - lastVideoProcessingNotificationTime < VIDEO_PROCESSING_NOTIFICATION_DELAY) {
return;
}
lastVideoProcessingNotificationTime = now;
actions.showNotification({
message: {
key: 'VideoConversionText',
},
title: {
key: 'VideoConversionTitle',
},
tabId,
});
actions.focusMessage({
chatId,
messageId: newScheduledMessageId,
messageListType: 'scheduled',
tabId,
});
});
break;
}
case 'updateMessageSendSucceeded': {
const {
chatId, localId, message, poll,
} = update;
global = updateListedAndViewportIds(global, actions, message as ApiMessage);
const currentMessage = selectChatMessage(global, chatId, localId);
global = deleteChatMessages(global, chatId, [localId]);
// Edge case for "Send When Online"
if (message.isScheduled) {
global = deleteChatScheduledMessages(global, chatId, [localId]);
}
global = updateChatMessage(global, chatId, message.id, {
...currentMessage,
...message,
previousLocalId: localId,
isDeleting: undefined,
});
if (poll) {
global = updatePoll(global, poll.id, poll);
}
global = {
...global,
fileUploads: {
byMessageKey: omit(global.fileUploads.byMessageKey, [getMessageKey(message)]),
},
};
const newMessage = selectChatMessage(global, chatId, message.id)!;
global = updateChatLastMessage(global, chatId, newMessage);
const thread = selectThreadByMessage(global, message);
// For some reason Telegram requires to manually mark outgoing thread messages read
Object.values(global.byTabId).forEach(({ id: tabId }) => {
const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {};
if (currentChatId !== chatId
|| (thread?.threadInfo?.threadId || MAIN_THREAD_ID) !== currentThreadId) {
return;
}
actions.markMessageListRead({ maxId: message.id, tabId });
});
if (thread?.threadInfo?.threadId) {
global = replaceThreadParam(global, chatId, thread.threadInfo.threadId, 'threadInfo', {
...thread.threadInfo,
lastMessageId: message.id,
lastReadInboxMessageId: message.id,
});
}
global = updateChat(global, chatId, {
lastReadInboxMessageId: message.id,
});
const chat = selectChat(global, chatId);
// Reload dialogs if chat is not present in the list
if (!chat?.isNotJoined && !selectIsChatListed(global, chatId)) {
actions.loadTopChats();
}
if (selectIsChatWithSelf(global, chatId)) {
const savedDialogId = selectSavedDialogIdFromMessage(global, newMessage);
if (savedDialogId && !selectIsChatListed(global, savedDialogId, 'saved')) {
actions.requestSavedDialogUpdate({ chatId: savedDialogId });
}
}
setGlobal(global);
break;
}
case 'updateScheduledMessageSendSucceeded': {
const {
chatId, localId, message, poll,
} = update;
const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || [];
global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', [...scheduledIds, message.id]);
const threadId = selectThreadIdFromMessage(global, message);
if (threadId !== MAIN_THREAD_ID) {
const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || [];
global = replaceThreadParam(global, chatId, threadId, 'scheduledIds', [...threadScheduledIds, message.id]);
}
const currentMessage = selectScheduledMessage(global, chatId, localId);
global = deleteChatScheduledMessages(global, chatId, [localId]);
global = updateScheduledMessage(global, chatId, message.id, {
...currentMessage,
...message,
previousLocalId: localId,
isDeleting: undefined,
});
if (poll) {
global = updatePoll(global, poll.id, poll);
}
setGlobal(global);
break;
}
case 'updatePinnedIds': {
const { chatId, isPinned, messageIds } = update;
const messages = pickTruthy(selectChatMessages(global, chatId), messageIds);
const updatePerThread: Record<ThreadId, number[]> = {
[MAIN_THREAD_ID]: messageIds,
};
Object.values(messages).forEach((message) => {
const threadId = selectThreadIdFromMessage(global, message);
global = updateChatMessage(global, chatId, message.id, {
isPinned,
});
if (threadId === MAIN_THREAD_ID) return;
const currentUpdatedInThread = updatePerThread[threadId] || [];
currentUpdatedInThread.push(message.id);
updatePerThread[threadId] = currentUpdatedInThread;
});
Object.entries(updatePerThread).forEach(([threadId, ids]) => {
const pinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID) || [];
const newPinnedIds = isPinned
? unique(pinnedIds.concat(ids)).sort((a, b) => b - a)
: pinnedIds.filter((id) => !ids.includes(id));
global = replaceThreadParam(global, chatId, Number(threadId), 'pinnedIds', newPinnedIds);
});
setGlobal(global);
break;
}
case 'updateThreadInfo': {
const {
threadInfo,
} = update;
global = updateThreadInfos(global, [threadInfo]);
const { chatId, threadId } = threadInfo;
if (!chatId || !threadId) return;
const chat = selectChat(global, chatId);
const currentThreadInfo = selectThreadInfo(global, chatId, threadId);
const topic = selectTopic(global, chatId, threadId);
if (chat?.isForum) {
if (!topic || topic.lastMessageId !== currentThreadInfo?.lastReadInboxMessageId) {
actions.loadTopicById({ chatId, topicId: Number(threadId) });
} else {
global = updateTopic(global, chatId, Number(threadId), {
unreadCount: 0,
});
}
}
// Update reply thread last read message id if already read in main thread
if (!chat?.isForum) {
const lastReadInboxMessageId = chat?.lastReadInboxMessageId;
const lastReadInboxMessageIdInThread = threadInfo.lastReadInboxMessageId || lastReadInboxMessageId;
if (lastReadInboxMessageId && lastReadInboxMessageIdInThread) {
global = updateThreadInfo(global, chatId, threadId, {
lastReadInboxMessageId: Math.max(lastReadInboxMessageIdInThread, lastReadInboxMessageId),
});
}
}
setGlobal(global);
break;
}
case 'resetMessages': {
const { id: chatId } = update;
const messagesById = selectChatMessages(global, chatId);
if (messagesById && !isUserId(chatId)) {
const tabId = getCurrentTabId();
global = deleteChatMessages(global, chatId, Object.keys(messagesById).map(Number));
setGlobal(global);
actions.loadFullChat({ chatId, force: true });
actions.loadViewportMessages({ chatId, threadId: MAIN_THREAD_ID, tabId });
}
break;
}
case 'deleteMessages': {
const { ids, chatId } = update;
deleteMessages(global, chatId, ids, actions);
break;
}
case 'deleteScheduledMessages': {
const { ids, newIds, chatId } = update;
const hadVideoProcessing = ids?.some((id) => (
selectScheduledMessage(global, chatId, id)?.isVideoProcessingPending
));
const processedVideoId = newIds?.find((id) => {
const message = selectChatMessage(global, chatId, id);
return message?.content.video;
});
if (hadVideoProcessing && processedVideoId) {
Object.values(global.byTabId).forEach(({ id: tabId }) => {
actions.showNotification({
message: {
key: 'VideoConversionDone',
},
actionText: {
key: 'VideoConversionView',
},
action: {
action: 'focusMessage',
payload: {
chatId,
messageId: processedVideoId,
tabId,
},
},
tabId,
});
});
}
deleteScheduledMessages(chatId, ids, actions, global);
break;
}
case 'deleteHistory': {
const { chatId } = update;
const chatMessages = global.messages.byChatId[chatId];
if (chatId === SERVICE_NOTIFICATIONS_USER_ID) {
global = {
...global,
serviceNotifications: global.serviceNotifications.map((notification) => ({
...notification,
isDeleted: true,
})),
};
setGlobal(global);
}
if (chatMessages) {
const ids = Object.keys(chatMessages.byId).map(Number);
global = getGlobal();
deleteMessages(global, chatId, ids, actions);
} else {
actions.requestChatUpdate({ chatId });
}
global = getGlobal();
global = removeChatFromChatLists(global, chatId);
setGlobal(global);
break;
}
case 'deleteSavedHistory': {
const { chatId } = update;
const currentUserId = global.currentUserId!;
global = removeChatFromChatLists(global, chatId, 'saved');
setGlobal(global);
global = getGlobal();
deleteThread(global, currentUserId, chatId, actions);
break;
}
case 'deleteParticipantHistory': {
const { chatId, peerId } = update;
global = getGlobal();
deleteParticipantHistory(global, chatId, peerId, actions);
break;
}
case 'updateCommonBoxMessages': {
const { ids, messageUpdate } = update;
ids.forEach((id) => {
const chatId = selectCommonBoxChatId(global, id);
if (chatId) {
global = updateChatMessage(global, chatId, id, messageUpdate);
}
});
setGlobal(global);
break;
}
case 'updateChannelMessages': {
const { channelId, ids, messageUpdate } = update;
ids.forEach((id) => {
global = updateChatMessage(global, channelId, id, messageUpdate);
});
setGlobal(global);
break;
}
case 'updateMessagePoll': {
const { pollId, pollUpdate } = update;
global = updatePoll(global, pollId, pollUpdate);
setGlobal(global);
break;
}
case 'updateMessagePollVote': {
const { pollId, peerId, options } = update;
global = updatePollVote(global, pollId, peerId, options);
setGlobal(global);
break;
}
case 'updateServiceNotification': {
const { message } = update;
if (selectIsServiceChatReady(global)) {
actions.createServiceNotification({ message });
}
break;
}
case 'updateMessageReactions': {
const { chatId, id, reactions } = update;
const message = selectChatMessage(global, chatId, id);
const chat = selectChat(global, update.chatId);
if (!chat || !message) return;
global = updateReactions(global, actions, chatId, id, reactions, chat, message.isOutgoing, message);
setGlobal(global);
break;
}
case 'updateMessageExtendedMedia': {
const {
chatId, id, extendedMedia, isBought,
} = update;
const message = selectChatMessage(global, chatId, id);
const chat = selectChat(global, update.chatId);
if (!chat || !message) return;
if (message.content.invoice) {
const media = extendedMedia[0];
if ('mediaType' in media && media.mediaType === 'extendedMediaPreview') {
if (!message.content.invoice) return;
global = updateChatMessage(global, chatId, id, {
content: {
...message.content,
invoice: {
...message.content.invoice,
extendedMedia: media,
},
},
});
setGlobal(global);
} else {
const content = media as MediaContent;
global = updateChatMessage(global, chatId, id, {
content: {
...content,
},
});
setGlobal(global);
}
}
if (message.content.paidMedia) {
const paidMediaUpdate = isBought ? { isBought, extendedMedia }
: { extendedMedia: extendedMedia as ApiMediaExtendedPreview[], isBought: undefined };
global = updateChatMessage(global, chatId, id, {
content: {
...message.content,
paidMedia: {
...message.content.paidMedia,
...paidMediaUpdate,
},
},
});
setGlobal(global);
}
break;
}
case 'updateTranscribedAudio': {
const { transcriptionId, text, isPending } = update;
global = {
...global,
transcriptions: {
...global.transcriptions,
[transcriptionId]: {
...(global.transcriptions[transcriptionId] || {}),
transcriptionId,
text,
isPending,
},
},
};
setGlobal(global);
break;
}
case 'updateMessageSendFailed': {
const { chatId, localId, error } = update;
if (error.match(/CHAT_SEND_.+?FORBIDDEN/)) {
Object.values(global.byTabId).forEach(({ id: tabId }) => {
actions.showAllowedMessageTypesNotification({ chatId, tabId });
});
}
global = updateChatMessage(global, chatId, localId, { sendingState: 'messageSendingStateFailed' });
setGlobal(global);
break;
}
case 'updateMessageTranslations': {
const {
chatId, messageIds, toLanguageCode, translations,
} = update;
global = updateMessageTranslations(global, chatId, messageIds, toLanguageCode, translations);
setGlobal(global);
break;
}
}
});
function updateReactions<T extends GlobalState>(
global: T,
actions: RequiredGlobalActions,
chatId: string,
id: number,
reactions: ApiReactions,
chat: ApiChat,
isOutgoing?: boolean,
message?: ApiMessage,
): T {
const currentReactions = message?.reactions;
// `updateMessageReactions` happens with an interval, so we try to avoid redundant global state updates
if (currentReactions && areDeepEqual(reactions, currentReactions)) {
return global;
}
const localPaidReaction = currentReactions?.results.find((r) => r.localAmount);
// Save local count on update, but reset if we sent reaction
if (localPaidReaction?.localAmount) {
const { localIsPrivate: isPrivate, localAmount, localPeerId } = localPaidReaction;
reactions.results = addPaidReaction(reactions.results, localAmount, isPrivate, localPeerId);
}
global = updateChatMessage(global, chatId, id, { reactions });
if (!isOutgoing) {
return global;
}
const { reaction, isOwn, isUnread } = reactions.recentReactions?.[0] ?? {};
const reactionEffectsEnabled = selectPerformanceSettingsValue(global, 'reactionEffects');
if (reactionEffectsEnabled && message && reaction && isUnread && !isOwn) {
const messageKey = getMessageKey(message);
// Start reaction only in master tab
actions.startActiveReaction({ containerId: messageKey, reaction, tabId: getCurrentTabId() });
}
const hasUnreadReactionsForMessageInChat = chat.unreadReactions?.includes(id);
const hasUnreadReactionsInNewReactions = checkIfHasUnreadReactions(global, reactions);
// Only notify about added reactions, not removed ones
if (hasUnreadReactionsInNewReactions && !hasUnreadReactionsForMessageInChat) {
global = updateUnreadReactions(global, chatId, {
unreadReactionsCount: (chat?.unreadReactionsCount || 0) + 1,
unreadReactions: [...(chat?.unreadReactions || []), id].sort((a, b) => b - a),
});
const newMessage = selectChatMessage(global, chatId, id);
if (!chat || !newMessage) return global;
onTickEnd(() => {
notifyAboutMessage({
chat,
message: newMessage,
isReaction: true,
});
});
}
if (!hasUnreadReactionsInNewReactions && hasUnreadReactionsForMessageInChat) {
global = updateUnreadReactions(global, chatId, {
unreadReactionsCount: (chat?.unreadReactionsCount || 1) - 1,
unreadReactions: chat?.unreadReactions?.filter((i) => i !== id),
});
}
return global;
}
function updateWithLocalMedia(
global: RequiredGlobalState,
chatId: string,
id: number,
messageUpdate: Partial<ApiMessage>,
isScheduled = false,
) {
const currentMessage = isScheduled
? selectScheduledMessage(global, chatId, id)
: selectChatMessage(global, chatId, id);
// Preserve locally uploaded media.
if (currentMessage && messageUpdate.content && !isLocalMessageId(id)) {
const {
photo, video, sticker, document,
} = getMessageContent(currentMessage);
if (photo && messageUpdate.content.photo) {
messageUpdate.content.photo.blobUrl ??= photo.blobUrl;
messageUpdate.content.photo.thumbnail ??= photo.thumbnail;
} else if (video && messageUpdate.content.video) {
messageUpdate.content.video.blobUrl ??= video.blobUrl;
} else if (sticker && messageUpdate.content.sticker) {
messageUpdate.content.sticker.isPreloadedGlobally ??= sticker.isPreloadedGlobally;
} else if (document && messageUpdate.content.document) {
messageUpdate.content.document.previewBlobUrl ??= document.previewBlobUrl;
}
}
const newMessage = currentMessage ? { ...currentMessage, ...messageUpdate } : messageUpdate;
return isScheduled
? updateScheduledMessage(global, chatId, id, newMessage)
: updateChatMessage(global, chatId, id, newMessage);
}
function updateThreadUnread<T extends GlobalState>(
global: T, actions: RequiredGlobalActions, message: ApiMessage, isDeleting?: boolean,
) {
const { chatId } = message;
const replyInfo = getMessageReplyInfo(message);
const { threadInfo } = selectThreadByMessage(global, message) || {};
if (!threadInfo && replyInfo?.replyToMsgId) {
const originMessage = selectChatMessage(global, chatId, replyInfo.replyToMsgId);
if (originMessage) {
global = updateThreadUnreadFromForwardedMessage(global, originMessage, chatId, message.id, isDeleting);
} else {
actions.loadMessage({
chatId,
messageId: replyInfo.replyToMsgId,
threadUpdate: {
isDeleting,
lastMessageId: message.id,
},
});
}
}
return global;
}
function updateListedAndViewportIds<T extends GlobalState>(
global: T, actions: RequiredGlobalActions, message: ApiMessage,
) {
const { id, chatId } = message;
const savedDialogId = selectSavedDialogIdFromMessage(global, message);
const { threadInfo } = selectThreadByMessage(global, message) || {};
const chat = selectChat(global, chatId);
const isUnreadChatNotLoaded = chat?.unreadCount && !selectListedIds(global, chatId, MAIN_THREAD_ID);
global = updateThreadUnread(global, actions, message);
const { threadId } = threadInfo ?? { threadId: savedDialogId };
if (threadId) {
global = updateListedIds(global, chatId, threadId, [id]);
Object.values(global.byTabId).forEach(({ id: tabId }) => {
if (selectIsViewportNewest(global, chatId, threadId, tabId)) {
// Always keep the first unread message in the viewport list
const firstUnreadId = selectFirstUnreadId(global, chatId, threadId);
const candidateGlobal = addViewportId(global, chatId, threadId, id, tabId);
const newViewportIds = selectViewportIds(candidateGlobal, chatId, threadId, tabId);
if (!firstUnreadId || newViewportIds!.includes(firstUnreadId)) {
global = candidateGlobal;
}
}
});
if (threadInfo) {
global = replaceThreadParam(global, chatId, threadId, 'threadInfo', {
...threadInfo,
lastMessageId: message.id,
});
if (!isMessageLocal(message) && !isActionMessage(message)) {
global = updateThreadInfo(global, chatId, threadId, {
messagesCount: (threadInfo.messagesCount || 0) + 1,
});
}
}
}
if (isUnreadChatNotLoaded) {
return global;
}
global = updateListedIds(global, chatId, MAIN_THREAD_ID, [id]);
Object.values(global.byTabId).forEach(({ id: tabId }) => {
if (selectIsViewportNewest(global, chatId, MAIN_THREAD_ID, tabId)) {
// Always keep the first unread message in the viewport list
const firstUnreadId = selectFirstUnreadId(global, chatId, MAIN_THREAD_ID);
const candidateGlobal = addViewportId(global, chatId, MAIN_THREAD_ID, id, tabId);
const newViewportIds = selectViewportIds(candidateGlobal, chatId, MAIN_THREAD_ID, tabId);
if (!firstUnreadId || newViewportIds!.includes(firstUnreadId)) {
global = candidateGlobal;
}
}
});
return global;
}
function updateChatLastMessage<T extends GlobalState>(
global: T,
chatId: string,
message: ApiMessage,
force = false,
) {
const { chats } = global;
const chat = chats.byId[chatId];
const currentLastMessageId = selectChatLastMessageId(global, chatId);
const topic = chat?.isForum ? selectTopicFromMessage(global, message) : undefined;
if (topic) {
global = updateTopic(global, chatId, topic.id, {
lastMessageId: message.id,
});
}
const savedDialogId = selectSavedDialogIdFromMessage(global, message);
if (savedDialogId) {
global = updateChatLastMessageId(global, savedDialogId, message.id, 'saved');
}
if (currentLastMessageId && !force) {
const isSameOrNewer = (
currentLastMessageId === message.id || currentLastMessageId === message.previousLocalId
) || message.id > currentLastMessageId;
if (!isSameOrNewer) {
return global;
}
}
global = updateChatLastMessageId(global, chatId, message.id);
return global;
}
function findLastMessage<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId = MAIN_THREAD_ID) {
const byId = selectChatMessages(global, chatId);
const listedIds = selectListedIds(global, chatId, threadId);
if (!byId || !listedIds) {
return undefined;
}
let i = listedIds.length;
while (i--) {
const message = byId[listedIds[i]];
if (message && !message.isDeleting) {
return message;
}
}
return undefined;
}
export function deleteParticipantHistory<T extends GlobalState>(
global: T,
chatId: string,
peerId: string,
actions: RequiredGlobalActions,
) {
const byId = selectChatMessages(global, chatId);
const messageIds = Object.values(byId).filter((message) => {
return message.senderId === peerId;
}).map((message) => message.id);
if (!messageIds.length) {
return;
}
deleteMessages(global, chatId, messageIds, actions);
}
export function deleteThread<T extends GlobalState>(
global: T,
chatId: string,
threadId: ThreadId,
actions: RequiredGlobalActions,
) {
const byId = selectChatMessages(global, chatId);
if (!byId) {
return;
}
const messageIds = Object.values(byId).filter((message) => {
const messageThreadId = selectThreadIdFromMessage(global, message);
return messageThreadId === threadId;
}).map((message) => message.id);
if (!messageIds.length) {
return;
}
deleteMessages(global, chatId, messageIds, actions);
}
export function deleteMessages<T extends GlobalState>(
global: T, chatId: string | undefined, ids: number[], actions: RequiredGlobalActions,
) {
// Channel update
if (chatId) {
const chat = selectChat(global, chatId);
if (!chat) return;
const threadIdsToUpdate = new Set<ThreadId>();
threadIdsToUpdate.add(MAIN_THREAD_ID);
ids.forEach((id) => {
global = updateChatMessage(global, chatId, id, {
isDeleting: true,
});
if (selectTopic(global, chatId, id)) {
global = deleteTopic(global, chatId, id);
}
const message = selectChatMessage(global, chatId, id);
if (!message) {
return;
}
if (message.content.action?.type === 'chatEditPhoto' && message.content.action.photo) {
global = deletePeerPhoto(global, chatId, message.content.action.photo.id, true);
}
global = updateThreadUnread(global, actions, message, true);
const threadId = selectThreadIdFromMessage(global, message);
if (threadId) {
threadIdsToUpdate.add(threadId);
}
});
actions.requestChatUpdate({ chatId });
const idsSet = new Set(ids);
threadIdsToUpdate.forEach((threadId) => {
const threadInfo = selectThreadInfo(global, chatId, threadId);
if (!threadInfo?.lastMessageId || !idsSet.has(threadInfo.lastMessageId)) return;
const newLastMessage = findLastMessage(global, chatId, threadId);
if (!newLastMessage) {
if (chat.isForum && threadId !== MAIN_THREAD_ID) {
actions.loadTopicById({ chatId, topicId: Number(threadId) });
}
return;
}
if (threadId === MAIN_THREAD_ID) {
global = updateChatLastMessage(global, chatId, newLastMessage, true);
}
global = updateThreadInfo(global, chatId, threadId, {
lastMessageId: newLastMessage.id,
});
if (chat.isForum) {
global = updateTopic(global, chatId, Number(threadId), {
lastMessageId: newLastMessage.id,
});
}
});
setGlobal(global);
const isAnimatingAsSnap = selectCanAnimateSnapEffect(global);
setTimeout(() => {
global = getGlobal();
// Prevent local deletion of sent messages in case of desync
const stillDeletedIds = ids.filter((id) => selectChatMessage(global, chatId, id)?.isDeleting);
global = deleteChatMessages(global, chatId, stillDeletedIds);
setGlobal(global);
}, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY);
return;
}
// Common box update
const chatIdsToUpdate: string[] = [];
ids.forEach((id) => {
const commonBoxChatId = selectCommonBoxChatId(global, id);
if (commonBoxChatId) {
chatIdsToUpdate.push(commonBoxChatId);
global = updateChatMessage(global, commonBoxChatId, id, {
isDeleting: true,
});
const newLastMessage = findLastMessage(global, commonBoxChatId);
if (newLastMessage) {
global = updateChatLastMessage(global, commonBoxChatId, newLastMessage, true);
}
const message = selectChatMessage(global, commonBoxChatId, id);
if (selectIsChatWithSelf(global, commonBoxChatId) && message) {
const threadId = selectThreadIdFromMessage(global, message);
if (getIsSavedDialog(commonBoxChatId, threadId, global.currentUserId)) {
const newLastSavedDialogMessage = findLastMessage(global, commonBoxChatId, threadId);
actions.requestSavedDialogUpdate({ chatId: String(threadId) });
if (newLastSavedDialogMessage) {
global = updateChatLastMessageId(global, commonBoxChatId, newLastSavedDialogMessage.id, 'saved');
}
}
}
if (message?.content.action?.type === 'chatEditPhoto' && message.content.action.photo) {
global = deletePeerPhoto(global, commonBoxChatId, message.content.action.photo.id, true);
}
const isAnimatingAsSnap = selectCanAnimateSnapEffect(global);
setTimeout(() => {
global = getGlobal();
global = deleteChatMessages(global, commonBoxChatId, [id]);
setGlobal(global);
}, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY);
}
});
setGlobal(global);
unique(chatIdsToUpdate).forEach((id) => {
actions.requestChatUpdate({ chatId: id });
});
}
function deleteScheduledMessages<T extends GlobalState>(
chatId: string, ids: number[], actions: RequiredGlobalActions, global: T,
) {
ids.forEach((id) => {
global = updateScheduledMessage(global, chatId, id, {
isDeleting: true,
});
});
setGlobal(global);
const isAnimatingAsSnap = selectCanAnimateSnapEffect(global);
setTimeout(() => {
global = getGlobal();
global = deleteChatScheduledMessages(global, chatId, ids);
const scheduledMessages = selectChatScheduledMessages(global, chatId);
global = replaceThreadParam(
global, chatId, MAIN_THREAD_ID, 'scheduledIds', Object.keys(scheduledMessages || {}).map(Number),
);
setGlobal(global);
}, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY);
}