580 lines
17 KiB
TypeScript

import type { ApiChat, ApiMessage, ApiUpdateChat } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { isLocalMessageId } from '../../../util/keys/messageKey';
import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications';
import { checkIfHasUnreadReactions, isChatChannel } from '../../helpers';
import {
addActionHandler, getGlobal, setGlobal,
} from '../../index';
import {
addChatListIds,
addUnreadMentions,
deleteChatMessages,
deletePeerPhoto,
leaveChat,
removeUnreadMentions,
replacePeerPhotos,
replacePinnedTopicIds,
replaceThreadParam,
updateChat,
updateChatFullInfo,
updateChatListType,
updatePeerStoriesHidden,
updateTopic,
} from '../../reducers';
import { updateUnreadReactions } from '../../reducers/reactions';
import { updateTabState } from '../../reducers/tabs';
import {
selectChat,
selectChatFullInfo,
selectChatListType,
selectChatMessages,
selectCommonBoxChatId,
selectCurrentMessageList,
selectIsChatListed,
selectPeer,
selectTabState,
selectThreadParam,
selectTopicFromMessage,
} from '../../selectors';
const TYPING_STATUS_CLEAR_DELAY = 6000; // 6 seconds
const INVALIDATE_FULL_CHAT_FIELDS = new Set<keyof ApiChat>([
'boostLevel', 'isForum', 'isLinkedInDiscussion', 'fakeType', 'restrictionReason', 'isJoinToSend', 'isJoinRequest',
'type',
]);
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
switch (update['@type']) {
case 'updateChat': {
const localChat = selectChat(global, update.id);
const { isForum: prevIsForum, lastReadOutboxMessageId } = localChat || {};
if (update.chat.lastReadOutboxMessageId && lastReadOutboxMessageId
&& update.chat.lastReadOutboxMessageId < lastReadOutboxMessageId) {
update = {
...update,
chat: omit(update.chat, ['lastReadInboxMessageId']),
};
}
global = updateChat(global, update.id, update.chat);
if (localChat?.areStoriesHidden !== update.chat.areStoriesHidden) {
global = updatePeerStoriesHidden(global, update.id, update.chat.areStoriesHidden || false);
}
setGlobal(global);
const updatedChat = selectChat(global, update.id);
if (!update.noTopChatsRequest && !selectIsChatListed(global, update.id)
&& !updatedChat?.isNotJoined) {
// Reload top chats to update chat listing
actions.loadTopChats();
}
if (update.chat.id) {
closeMessageNotifications({
chatId: update.chat.id,
lastReadInboxMessageId: update.chat.lastReadInboxMessageId,
});
}
Object.values(global.byTabId).forEach(({ id: tabId }) => {
const { chatId: currentChatId } = selectCurrentMessageList(global, tabId) || {};
const chatUpdate = update as ApiUpdateChat;
// The property `isForum` was changed in another client
if (currentChatId === chatUpdate.id
&& 'isForum' in chatUpdate.chat && prevIsForum !== chatUpdate.chat.isForum) {
if (prevIsForum) {
actions.closeForumPanel({ tabId });
}
actions.openChat({ id: currentChatId, tabId });
}
});
if (localChat) {
const chatUpdate = update.chat;
const changedFields = (Object.keys(chatUpdate) as (keyof ApiChat)[])
.filter((key) => localChat[key] !== chatUpdate[key]);
if (changedFields.some((key) => INVALIDATE_FULL_CHAT_FIELDS.has(key))) {
actions.invalidateFullInfo({ peerId: update.id });
}
}
return undefined;
}
case 'updateChatJoin': {
const listType = selectChatListType(global, update.id);
const chat = selectChat(global, update.id);
global = updateChat(global, update.id, { isNotJoined: false });
setGlobal(global);
if (chat) {
actions.requestChatUpdate({ chatId: chat.id });
}
actions.loadFullChat({ chatId: update.id, force: true });
if (!listType) {
return undefined;
}
global = getGlobal();
global = addChatListIds(global, listType, [update.id]);
setGlobal(global);
return undefined;
}
case 'updateChatLeave': {
global = leaveChat(global, update.id);
const chat = selectChat(global, update.id);
if (chat && isChatChannel(chat)) {
const chatMessages = selectChatMessages(global, update.id);
if (chatMessages) {
const localMessageIds = Object.keys(chatMessages).map(Number).filter(isLocalMessageId);
global = deleteChatMessages(global, chat.id, localMessageIds);
}
}
return global;
}
case 'updateChatInbox': {
return updateChat(global, update.id, update.chat);
}
case 'updateChatTypingStatus': {
const { id, threadId = MAIN_THREAD_ID, typingStatus } = update;
global = replaceThreadParam(global, id, threadId, 'typingStatus', typingStatus);
setGlobal(global);
setTimeout(() => {
global = getGlobal();
const currentTypingStatus = selectThreadParam(global, id, threadId, 'typingStatus');
if (typingStatus && currentTypingStatus && typingStatus.timestamp === currentTypingStatus.timestamp) {
global = replaceThreadParam(global, id, threadId, 'typingStatus', undefined);
setGlobal(global);
}
}, TYPING_STATUS_CLEAR_DELAY);
return undefined;
}
case 'newMessage': {
const { message } = update;
const isOur = message.senderId ? message.senderId === global.currentUserId : message.isOutgoing;
if (isOur && !message.isFromScheduled) {
return undefined;
}
const isLocal = isLocalMessageId(message.id!);
const chat = selectChat(global, update.chatId);
if (!chat) {
return undefined;
}
const hasMention = Boolean(update.message.id && update.message.hasUnreadMention);
if (!isLocal || chat.id === SERVICE_NOTIFICATIONS_USER_ID) {
global = updateChat(global, update.chatId, {
unreadCount: chat.unreadCount ? chat.unreadCount + 1 : 1,
});
if (hasMention) {
global = addUnreadMentions(global, update.chatId, chat, [update.message.id!], true);
}
const topic = chat.isForum ? selectTopicFromMessage(global, message as ApiMessage) : undefined;
if (topic) {
global = updateTopic(global, update.chatId, topic.id, {
unreadCount: topic.unreadCount ? topic.unreadCount + 1 : 1,
});
}
}
setGlobal(global);
notifyAboutMessage({
chat,
message,
});
return undefined;
}
case 'updateCommonBoxMessages':
case 'updateChannelMessages': {
const { ids, messageUpdate } = update;
ids.forEach((id) => {
const chatId = ('channelId' in update ? update.channelId : selectCommonBoxChatId(global, id))!;
const chat = selectChat(global, chatId);
if (messageUpdate.reactions && chat?.unreadReactionsCount
&& !checkIfHasUnreadReactions(global, messageUpdate.reactions)) {
global = updateUnreadReactions(global, chatId, {
unreadReactionsCount: Math.max(chat.unreadReactionsCount - 1, 0) || undefined,
unreadReactions: chat.unreadReactions?.filter((i) => i !== id),
});
}
if (!messageUpdate.hasUnreadMention && chat?.unreadMentionsCount) {
global = removeUnreadMentions(global, chatId, chat, [id], true);
}
});
return global;
}
case 'updateChatFullInfo': {
return updateChatFullInfo(global, update.id, update.fullInfo);
}
case 'updatePinnedChatIds': {
const { ids, folderId } = update;
const listType = folderId === ARCHIVED_FOLDER_ID ? 'archived' : 'active';
if (!ids) {
actions.loadPinnedDialogs({ listType });
return global;
}
return {
...global,
chats: {
...global.chats,
orderedPinnedIds: {
...global.chats.orderedPinnedIds,
[listType]: ids.length ? ids : undefined,
},
},
};
}
case 'updatePinnedSavedDialogIds': {
const { ids } = update;
return {
...global,
chats: {
...global.chats,
orderedPinnedIds: {
...global.chats.orderedPinnedIds,
saved: ids.length ? ids : undefined,
},
},
};
}
case 'updateChatPinned': {
const { id, isPinned } = update;
const listType = selectChatListType(global, id);
if (!listType) {
return undefined;
}
const { [listType]: orderedPinnedIds } = global.chats.orderedPinnedIds;
let newOrderedPinnedIds = orderedPinnedIds || [];
if (!isPinned) {
newOrderedPinnedIds = newOrderedPinnedIds.filter((pinnedId) => pinnedId !== id);
} else if (!newOrderedPinnedIds.includes(id)) {
// When moving pinned chats to archive, active ordered pinned ids don't get updated
// (to preserve chat pinned state when it returns from archive)
// If user already has max pinned chats, we should check for orderedIds
// that don't point to listed chats
if (listType === 'active' && newOrderedPinnedIds.length >= MAX_ACTIVE_PINNED_CHATS) {
const listIds = global.chats.listIds.active;
newOrderedPinnedIds = newOrderedPinnedIds.filter((pinnedId) => listIds && listIds.includes(pinnedId));
}
newOrderedPinnedIds = [id, ...newOrderedPinnedIds];
}
return {
...global,
chats: {
...global.chats,
orderedPinnedIds: {
...global.chats.orderedPinnedIds,
[listType]: newOrderedPinnedIds.length ? newOrderedPinnedIds : undefined,
},
},
};
}
case 'updateSavedDialogPinned': {
const { id, isPinned } = update;
const { saved: orderedPinnedIds } = global.chats.orderedPinnedIds;
let newOrderedPinnedIds = orderedPinnedIds || [];
if (!isPinned) {
newOrderedPinnedIds = newOrderedPinnedIds.filter((pinnedId) => pinnedId !== id);
} else if (!newOrderedPinnedIds.includes(id)) {
newOrderedPinnedIds = [id, ...newOrderedPinnedIds];
}
return {
...global,
chats: {
...global.chats,
orderedPinnedIds: {
...global.chats.orderedPinnedIds,
saved: newOrderedPinnedIds.length ? newOrderedPinnedIds : undefined,
},
},
};
}
case 'updateChatListType': {
const { id, folderId } = update;
return updateChatListType(global, id, folderId);
}
case 'updateChatFolder': {
const { id, folder } = update;
const { byId: chatFoldersById, orderedIds } = global.chatFolders;
const isDeleted = folder === undefined;
Object.values(global.byTabId).forEach(({ id: tabId }) => {
const tabState = selectTabState(global, tabId);
const isFolderActive = Object.values(chatFoldersById)[tabState.activeChatFolder - 1]?.id === id;
if (isFolderActive) {
global = updateTabState(global, { activeChatFolder: 0 }, tabId);
}
});
const newChatFoldersById = !isDeleted ? { ...chatFoldersById, [id]: folder } : omit(chatFoldersById, [id]);
const newOrderedIds = !isDeleted
? orderedIds?.includes(id) ? orderedIds : [...(orderedIds || []), id]
: orderedIds?.filter((orderedId) => orderedId !== id);
return {
...global,
chatFolders: {
...global.chatFolders,
byId: newChatFoldersById,
orderedIds: newOrderedIds,
invites: omit(global.chatFolders.invites, [id]),
},
};
}
case 'updateChatFoldersOrder': {
const { orderedIds } = update;
return {
...global,
chatFolders: {
...global.chatFolders,
orderedIds,
},
};
}
case 'updateRecommendedChatFolders': {
const { folders } = update;
return {
...global,
chatFolders: {
...global.chatFolders,
recommended: folders,
},
};
}
case 'updateChatMembers': {
const targetChatFullInfo = selectChatFullInfo(global, update.id);
const { replacedMembers, addedMember, deletedMemberId } = update;
if (!targetChatFullInfo) {
return undefined;
}
let shouldUpdate = false;
let members = targetChatFullInfo?.members
? [...targetChatFullInfo.members]
: [];
if (replacedMembers) {
members = replacedMembers;
shouldUpdate = true;
} else if (addedMember) {
if (
!members.length
|| !members.some((m) => m.userId === addedMember.userId)
) {
members.push(addedMember);
shouldUpdate = true;
}
} else if (members.length && deletedMemberId) {
const deleteIndex = members.findIndex((m) => m.userId === deletedMemberId);
if (deleteIndex > -1) {
members.slice(deleteIndex, 1);
shouldUpdate = true;
}
}
if (shouldUpdate) {
const adminMembers = members.filter(({ isOwner, isAdmin }) => isOwner || isAdmin);
// TODO Kicked members?
global = updateChat(global, update.id, { membersCount: members.length });
global = updateChatFullInfo(global, update.id, {
members,
adminMembersById: buildCollectionByKey(adminMembers, 'userId'),
});
return global;
}
return undefined;
}
case 'draftMessage': {
const {
chatId, threadId, draft,
} = update;
const chat = global.chats.byId[chatId];
if (!chat) {
return undefined;
}
global = replaceThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'draft', draft);
global = updateChat(global, chatId, { draftDate: draft?.date });
return global;
}
case 'updatePendingJoinRequests': {
const { chatId, requestsPending, recentRequesterIds } = update;
const chat = global.chats.byId[chatId];
if (!chat) {
return undefined;
}
global = updateChatFullInfo(global, chatId, {
requestsPending,
recentRequesterIds,
});
setGlobal(global);
actions.loadChatJoinRequests({ chatId });
return undefined;
}
case 'updatePinnedTopic': {
const { chatId, topicId, isPinned } = update;
const chat = global.chats.byId[chatId];
if (!chat) {
return undefined;
}
global = updateTopic(global, chatId, topicId, {
isPinned,
});
setGlobal(global);
return undefined;
}
case 'updatePinnedTopicsOrder': {
const { chatId, order } = update;
const chat = global.chats.byId[chatId];
if (!chat) return undefined;
global = replacePinnedTopicIds(global, chatId, order);
setGlobal(global);
return undefined;
}
case 'updateTopic': {
const { chatId, topicId } = update;
const chat = selectChat(global, chatId);
if (!chat?.isForum) return undefined;
actions.loadTopicById({ chatId, topicId });
return undefined;
}
case 'updateTopics': {
const { chatId } = update;
const chat = selectChat(global, chatId);
if (!chat?.isForum) return undefined;
actions.loadTopics({ chatId, force: true });
return undefined;
}
case 'updateViewForumAsMessages': {
const { chatId, isEnabled } = update;
const chat = selectChat(global, chatId);
if (!chat?.isForum) return undefined;
global = updateChat(global, chatId, {
isForumAsMessages: isEnabled,
});
setGlobal(global);
break;
}
case 'updateNewProfilePhoto': {
const { peerId, photo } = update;
global = updateChat(global, peerId, {
avatarPhotoId: photo.id,
});
setGlobal(global);
actions.loadMoreProfilePhotos({ peerId, shouldInvalidateCache: true });
break;
}
case 'updateDeleteProfilePhoto': {
const { peerId, photoId } = update;
const peer = selectPeer(global, peerId);
if (!peer) {
return undefined;
}
if (!photoId || peer.avatarPhotoId === photoId) {
global = updateChat(global, peerId, {
avatarPhotoId: undefined,
});
global = replacePeerPhotos(global, peerId, undefined);
} else {
global = deletePeerPhoto(global, peerId, photoId);
}
setGlobal(global);
actions.loadMoreProfilePhotos({ peerId, shouldInvalidateCache: true });
break;
}
}
return undefined;
});