import { addCallback } from '../lib/teact/teactn'; import { addActionHandler, getGlobal } from '../global'; import type { GlobalState } from '../global/types'; import type { NotifyException, NotifySettings } from '../types'; import type { ApiChat, ApiChatFolder, ApiUser } from '../api/types'; import { ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, DEBUG, SERVICE_NOTIFICATIONS_USER_ID, } from '../config'; import { selectNotifySettings, selectNotifyExceptions } from '../global/selectors'; import { selectIsChatMuted } from '../global/helpers'; import { onIdle, throttle } from './schedulers'; import { areSortedArraysEqual, unique } from './iteratees'; import arePropsShallowEqual from './arePropsShallowEqual'; import type { CallbackManager } from './callbacks'; import { createCallbackManager } from './callbacks'; interface FolderSummary { id: number; listIds?: Set; orderedPinnedIds?: string[]; contacts?: true; nonContacts?: true; groups?: true; channels?: true; bots?: true; excludeMuted?: true; excludeRead?: true; excludeArchived?: true; excludedChatIds?: Set; includedChatIds?: Set; pinnedChatIds?: Set; } interface ChatSummary { id: string; type: ApiChat['type']; isListed: boolean; isArchived: boolean; isMuted: boolean; isUnread: boolean; unreadCount?: number; unreadMentionsCount?: number; order: number; isUserBot?: boolean; isUserContact?: boolean; } const UPDATE_THROTTLE = 500; const DEBUG_DURATION_LIMIT = 6; const initials = buildInitials(); let prevGlobal: { allFolderListIds?: GlobalState['chats']['listIds']['active']; allFolderPinnedIds?: GlobalState['chats']['orderedPinnedIds']['active']; archivedFolderListIds?: GlobalState['chats']['listIds']['archived']; archivedFolderPinnedIds?: GlobalState['chats']['orderedPinnedIds']['archived']; isAllFolderFullyLoaded?: boolean; isArchivedFolderFullyLoaded?: boolean; chatsById: Record; foldersById: Record; usersById: Record; notifySettings: NotifySettings; notifyExceptions?: Record; } = initials.prevGlobal; let prepared: { folderSummariesById: Record; chatSummariesById: Map; folderIdsByChatId: Record; chatIdsByFolderId: Record | undefined>; isOrderedListJustPatched: Record; } = initials.prepared; let results: { orderedIdsByFolderId: Record; pinnedCountByFolderId: Record; // Also watched by `callbacks.orderedIdsByFolderId` chatsCountByFolderId: Record; unreadCountersByFolderId: Record; } = initials.results; let callbacks: { orderedIdsByFolderId: Record; chatsCountByFolderId: CallbackManager; unreadCountersByFolderId: CallbackManager; } = initials.callbacks; const updateFolderManagerThrottled = throttle(() => { onIdle(() => { updateFolderManager(getGlobal()); }); }, UPDATE_THROTTLE); let inited = false; /* Getters */ export function init() { inited = true; addCallback(updateFolderManagerThrottled); addActionHandler('reset', reset); updateFolderManager(getGlobal()); } export function getOrderedIds(folderId: number) { if (!inited) init(); return results.orderedIdsByFolderId[folderId]; } export function getPinnedChatsCount(folderId: number) { if (!inited) init(); return results.pinnedCountByFolderId[folderId] || 0; } export function getChatsCount() { if (!inited) init(); return results.chatsCountByFolderId; } export function getUnreadCounters() { if (!inited) init(); return results.unreadCountersByFolderId; } export function getAllNotificationsCount() { return getUnreadCounters()[ALL_FOLDER_ID]?.notificationsCount || 0; } export function getOrderKey(chatId: string) { return prepared.chatSummariesById.get(chatId)!.order; } /* Callback managers */ export function addOrderedIdsCallback(folderId: number, callback: (orderedIds: string[]) => void) { if (!callbacks.orderedIdsByFolderId[folderId]) { callbacks.orderedIdsByFolderId[folderId] = createCallbackManager(); } return callbacks.orderedIdsByFolderId[folderId].addCallback(callback); } export function addChatsCountCallback(callback: (chatsCount: typeof results.chatsCountByFolderId) => void) { return callbacks.chatsCountByFolderId.addCallback(callback); } export function addUnreadCountersCallback(callback: (unreadCounters: typeof results.unreadCountersByFolderId) => void) { return callbacks.unreadCountersByFolderId.addCallback(callback); } /* Global update handlers */ function updateFolderManager(global: GlobalState) { // eslint-disable-next-line @typescript-eslint/naming-convention let DEBUG_startedAt: number; if (DEBUG) { DEBUG_startedAt = performance.now(); } const isAllFolderChanged = Boolean( global.chats.listIds.active && isMainFolderChanged(ALL_FOLDER_ID, global.chats.listIds.active, global.chats.orderedPinnedIds.active), ); const isArchivedFolderChanged = Boolean( global.chats.listIds.archived && isMainFolderChanged(ARCHIVED_FOLDER_ID, global.chats.listIds.archived, global.chats.orderedPinnedIds.archived), ); const isAllFullyLoadedChanged = global.chats.isFullyLoaded.active !== prevGlobal.isAllFolderFullyLoaded; const isArchivedFullyLoadedChanged = global.chats.isFullyLoaded.archived !== prevGlobal.isArchivedFolderFullyLoaded; const areFoldersChanged = global.chatFolders.byId !== prevGlobal.foldersById; const areChatsChanged = global.chats.byId !== prevGlobal.chatsById; const areUsersChanged = global.users.byId !== prevGlobal.usersById; const areNotifySettingsChanged = selectNotifySettings(global) !== prevGlobal.notifySettings; const areNotifyExceptionsChanged = selectNotifyExceptions(global) !== prevGlobal.notifyExceptions; let affectedFolderIds: number[] = []; if (isAllFullyLoadedChanged || isArchivedFullyLoadedChanged) { affectedFolderIds = affectedFolderIds.concat( updateFullyLoaded(global, isArchivedFullyLoadedChanged), ); } if (!( isAllFolderChanged || isArchivedFolderChanged || areFoldersChanged || areChatsChanged || areUsersChanged || areNotifySettingsChanged || areNotifyExceptionsChanged ) ) { if (affectedFolderIds.length) { updateResults(affectedFolderIds); } return; } const prevAllFolderListIds = prevGlobal.allFolderListIds; const prevArchivedFolderListIds = prevGlobal.archivedFolderListIds; updateFolders(global, isAllFolderChanged, isArchivedFolderChanged, areFoldersChanged); affectedFolderIds = affectedFolderIds.concat(updateChats( global, areFoldersChanged || isAllFolderChanged || isArchivedFolderChanged, areNotifySettingsChanged, areNotifyExceptionsChanged, prevAllFolderListIds, prevArchivedFolderListIds, )); updateResults(unique(affectedFolderIds)); if (DEBUG) { const duration = performance.now() - DEBUG_startedAt!; if (duration > DEBUG_DURATION_LIMIT) { // eslint-disable-next-line no-console console.warn(`Slow \`updateFolderManager\`: ${Math.round(duration)} ms`); } } } function isMainFolderChanged(folderId: number, newListIds?: string[], newPinnedIds?: string[]) { const currentListIds = folderId === ALL_FOLDER_ID ? prevGlobal.allFolderListIds : prevGlobal.archivedFolderListIds; const currentPinnedIds = folderId === ALL_FOLDER_ID ? prevGlobal.allFolderPinnedIds : prevGlobal.archivedFolderPinnedIds; return currentListIds !== newListIds || currentPinnedIds !== newPinnedIds; } function updateFullyLoaded( global: GlobalState, isArchivedFullyLoadedChanged = false, ) { let affectedFolderIds = []; if (isArchivedFullyLoadedChanged) { affectedFolderIds.push(ARCHIVED_FOLDER_ID); } const isAllFolderFullyLoaded = global.chats.isFullyLoaded.active; const isArchivedFolderFullyLoaded = global.chats.isFullyLoaded.archived; if (isAllFolderFullyLoaded && isArchivedFolderFullyLoaded) { const emptyFolderIds = Object.keys(prepared.folderSummariesById) .filter((folderId) => !results.orderedIdsByFolderId[folderId]) .map(Number); affectedFolderIds = affectedFolderIds.concat(emptyFolderIds); } prevGlobal.isAllFolderFullyLoaded = isAllFolderFullyLoaded; prevGlobal.isArchivedFolderFullyLoaded = isArchivedFolderFullyLoaded; return affectedFolderIds; } function updateFolders( global: GlobalState, isAllFolderChanged: boolean, isArchivedFolderChanged: boolean, areFoldersChanged: boolean, ) { const changedFolders = []; if (isAllFolderChanged) { const newListIds = global.chats.listIds.active!; const newPinnedIds = global.chats.orderedPinnedIds.active; prepared.folderSummariesById[ALL_FOLDER_ID] = buildFolderSummaryFromMainList( ALL_FOLDER_ID, newListIds, newPinnedIds, ); prevGlobal.allFolderListIds = newListIds; prevGlobal.allFolderPinnedIds = newPinnedIds; changedFolders.push(ALL_FOLDER_ID); } if (isArchivedFolderChanged) { const newListIds = global.chats.listIds.archived!; const newPinnedIds = global.chats.orderedPinnedIds.archived; prepared.folderSummariesById[ARCHIVED_FOLDER_ID] = buildFolderSummaryFromMainList( ARCHIVED_FOLDER_ID, newListIds, newPinnedIds, ); prevGlobal.archivedFolderListIds = newListIds; prevGlobal.archivedFolderPinnedIds = newPinnedIds; changedFolders.push(ARCHIVED_FOLDER_ID); } if (areFoldersChanged) { const newFoldersById = global.chatFolders.byId; Object.values(newFoldersById).forEach((folder) => { if (folder === prevGlobal.foldersById[folder.id]) { return; } prepared.folderSummariesById[folder.id] = buildFolderSummary(folder); changedFolders.push(folder.id); }); prevGlobal.foldersById = newFoldersById; } return changedFolders; } function buildFolderSummaryFromMainList( folderId: number, listIds: string[], orderedPinnedIds?: string[], ): FolderSummary { return { id: folderId, listIds: new Set(listIds), orderedPinnedIds, pinnedChatIds: new Set(orderedPinnedIds), }; } function buildFolderSummary(folder: ApiChatFolder): FolderSummary { return { ...folder, orderedPinnedIds: folder.pinnedChatIds, excludedChatIds: folder.excludedChatIds ? new Set(folder.excludedChatIds) : undefined, includedChatIds: folder.excludedChatIds ? new Set(folder.includedChatIds) : undefined, pinnedChatIds: folder.excludedChatIds ? new Set(folder.pinnedChatIds) : undefined, }; } function updateChats( global: GlobalState, areFoldersChanged: boolean, areNotifySettingsChanged: boolean, areNotifyExceptionsChanged: boolean, prevAllFolderListIds?: string[], prevArchivedFolderListIds?: string[], ) { const newChatsById = global.chats.byId; const newUsersById = global.users.byId; const newNotifySettings = selectNotifySettings(global); const newNotifyExceptions = selectNotifyExceptions(global); const folderSummaries = Object.values(prepared.folderSummariesById); const affectedFolderIds = new Set(); const newAllFolderListIds = global.chats.listIds.active; const newArchivedFolderListIds = global.chats.listIds.archived; let allIds = [...newAllFolderListIds || [], ...newArchivedFolderListIds || []]; if (newAllFolderListIds !== prevAllFolderListIds || newArchivedFolderListIds !== prevArchivedFolderListIds) { allIds = unique(allIds.concat(prevAllFolderListIds || [], prevArchivedFolderListIds || [])); } allIds.forEach((chatId) => { const chat = newChatsById[chatId]; if ( !areFoldersChanged && !areNotifySettingsChanged && !areNotifyExceptionsChanged && chat === prevGlobal.chatsById[chatId] && newUsersById[chatId] === prevGlobal.usersById[chatId] ) { return; } let newFolderIds: number[]; if (chat) { const currentSummary = prepared.chatSummariesById.get(chatId); const newSummary = buildChatSummary(chat, newNotifySettings, newNotifyExceptions, newUsersById[chatId]); if (!areFoldersChanged && currentSummary && arePropsShallowEqual(newSummary, currentSummary)) { return; } prepared.chatSummariesById.set(chatId, newSummary); newFolderIds = buildChatFolderIds(newSummary, folderSummaries); newFolderIds.forEach((folderId) => { affectedFolderIds.add(folderId); }); } else { prepared.chatSummariesById.delete(chatId); newFolderIds = []; } const currentFolderIds = prepared.folderIdsByChatId[chatId] || []; if (areSortedArraysEqual(newFolderIds, currentFolderIds)) { return; } const deletedFolderIds = updateListsForChat(chatId, currentFolderIds, newFolderIds); deletedFolderIds.forEach((folderId) => { affectedFolderIds.add(folderId); }); }); prevGlobal.chatsById = newChatsById; prevGlobal.usersById = newUsersById; prevGlobal.notifySettings = newNotifySettings; prevGlobal.notifyExceptions = newNotifyExceptions; return Array.from(affectedFolderIds); } function buildChatSummary( chat: ApiChat, notifySettings: NotifySettings, notifyExceptions?: Record, user?: ApiUser, ): ChatSummary { const { id, type, lastMessage, isRestricted, isNotJoined, migratedTo, folderId, unreadCount, unreadMentionsCount, hasUnreadMark, joinDate, draftDate, } = chat; const userInfo = type === 'chatTypePrivate' && user; const shouldHideServiceChat = chat.id === SERVICE_NOTIFICATIONS_USER_ID && ( !chat.lastMessage || chat.lastMessage.content.action?.type === 'historyClear' ); return { id, type, isListed: Boolean(!isRestricted && !isNotJoined && !migratedTo && !shouldHideServiceChat), isArchived: folderId === ARCHIVED_FOLDER_ID, isMuted: selectIsChatMuted(chat, notifySettings, notifyExceptions), isUnread: Boolean(unreadCount || unreadMentionsCount || hasUnreadMark), unreadCount, unreadMentionsCount, order: Math.max(joinDate || 0, draftDate || 0, lastMessage?.date || 0), isUserBot: userInfo ? userInfo.type === 'userTypeBot' : undefined, isUserContact: userInfo ? userInfo.isContact : undefined, }; } function buildChatFolderIds(chatSummary: ChatSummary, folderSummaries: FolderSummary[]) { return folderSummaries.reduce((acc, folderSummary) => { if (isChatInFolder(chatSummary, folderSummary)) { acc.push(folderSummary.id); } return acc; }, []).sort(); } function isChatInFolder( chatSummary: ChatSummary, folderSummary: FolderSummary, ) { if (!chatSummary.isListed) { return false; } const { id: chatId, type } = chatSummary; if (folderSummary.listIds) { if ( (chatSummary.isArchived && folderSummary.id === ALL_FOLDER_ID) || (!chatSummary.isArchived && folderSummary.id === ARCHIVED_FOLDER_ID) ) { return false; } return folderSummary.listIds.has(chatId); } if (folderSummary.excludedChatIds?.has(chatId)) { return false; } if (folderSummary.includedChatIds?.has(chatId)) { return true; } if (folderSummary.pinnedChatIds?.has(chatId)) { return true; } if (folderSummary.excludeArchived && chatSummary.isArchived) { return false; } if (folderSummary.excludeRead && !chatSummary.isUnread) { return false; } if (folderSummary.excludeMuted && chatSummary.isMuted && !chatSummary.unreadMentionsCount) { return false; } if (type === 'chatTypePrivate') { if (chatSummary.isUserBot) { if (folderSummary.bots) { return true; } } else { if (folderSummary.contacts && chatSummary.isUserContact) { return true; } if (folderSummary.nonContacts && !chatSummary.isUserContact) { return true; } } } else if (type === 'chatTypeChannel') { return Boolean(folderSummary.channels); } else if (type === 'chatTypeBasicGroup' || type === 'chatTypeSuperGroup') { return Boolean(folderSummary.groups); } return false; } function updateListsForChat(chatId: string, currentFolderIds: number[], newFolderIds: number[]) { const currentFolderIdsSet = new Set(currentFolderIds); const newFolderIdsSet = new Set(newFolderIds); const deletedFolderIds: number[] = []; unique([...currentFolderIds, ...newFolderIds]).forEach((folderId) => { let currentFolderOrderedIds = results.orderedIdsByFolderId[folderId]; if (currentFolderIdsSet.has(folderId) && !newFolderIdsSet.has(folderId)) { prepared.chatIdsByFolderId[folderId]!.delete(chatId); deletedFolderIds.push(folderId); if (currentFolderOrderedIds) { currentFolderOrderedIds = currentFolderOrderedIds.filter((id) => id !== chatId); prepared.isOrderedListJustPatched[folderId] = true; } } else if (!currentFolderIdsSet.has(folderId) && newFolderIdsSet.has(folderId)) { if (!prepared.chatIdsByFolderId[folderId]) { prepared.chatIdsByFolderId[folderId] = new Set(); } prepared.chatIdsByFolderId[folderId]!.add(chatId); if (currentFolderOrderedIds) { currentFolderOrderedIds.push(chatId); prepared.isOrderedListJustPatched[folderId] = true; } } results.orderedIdsByFolderId[folderId] = currentFolderOrderedIds; }); prepared.folderIdsByChatId[chatId] = newFolderIds; return deletedFolderIds; } function updateResults(affectedFolderIds: number[]) { let wasUnreadCountersChanged = false; let wasChatsCountChanged = false; Array.from(affectedFolderIds).forEach((folderId) => { const { pinnedCount: newPinnedCount, orderedIds: newOrderedIds } = buildFolderOrderedIds(folderId); // When signed out if (!newOrderedIds) { return; } const currentOrderedIds = results.orderedIdsByFolderId[folderId]; const currentPinnedCount = results.pinnedCountByFolderId[folderId]; const areOrderedIdsChanged = ( !currentOrderedIds || currentPinnedCount === undefined || currentPinnedCount !== newPinnedCount || prepared.isOrderedListJustPatched[folderId] || !areSortedArraysEqual(newOrderedIds, currentOrderedIds) ); if (areOrderedIdsChanged) { prepared.isOrderedListJustPatched[folderId] = false; results.orderedIdsByFolderId[folderId] = newOrderedIds; results.pinnedCountByFolderId[folderId] = newPinnedCount; callbacks.orderedIdsByFolderId[folderId]?.runCallbacks(newOrderedIds); } const currentChatsCount = results.chatsCountByFolderId[folderId]; const newChatsCount = newOrderedIds.length; if (!wasChatsCountChanged) { wasChatsCountChanged = currentChatsCount !== newChatsCount; } results.chatsCountByFolderId[folderId] = newChatsCount; const currentUnreadCounters = results.unreadCountersByFolderId[folderId]; const newUnreadCounters = buildFolderUnreadCounters(folderId); if (!wasUnreadCountersChanged) { wasUnreadCountersChanged = ( !currentUnreadCounters || !arePropsShallowEqual(newUnreadCounters, currentUnreadCounters) ); } results.unreadCountersByFolderId[folderId] = newUnreadCounters; }); if (wasChatsCountChanged) { // We need to update the entire object as it will be returned from a hook const newValue = { ...results.chatsCountByFolderId }; results.chatsCountByFolderId = newValue; callbacks.chatsCountByFolderId.runCallbacks(newValue); } if (wasUnreadCountersChanged) { // We need to update the entire object as it will be returned from a hook const newValue = { ...results.unreadCountersByFolderId }; results.unreadCountersByFolderId = newValue; callbacks.unreadCountersByFolderId.runCallbacks(newValue); } } function buildFolderOrderedIds(folderId: number) { const folderSummary = prepared.folderSummariesById[folderId]; if (!folderSummary) { return {}; } const { orderedPinnedIds, pinnedChatIds } = folderSummary; const { chatSummariesById, chatIdsByFolderId: { [folderId]: chatIds }, } = prepared; const { orderedIdsByFolderId: { [folderId]: prevOrderedIds }, } = results; const sortedPinnedIds = chatIds ? orderedPinnedIds?.filter((id) => chatIds.has(id)) : orderedPinnedIds; const allListIds = prevOrderedIds || (chatIds && Array.from(chatIds)) || []; const notPinnedIds = pinnedChatIds ? allListIds.filter((id) => !pinnedChatIds.has(id)) : allListIds; const sortedNotPinnedIds = notPinnedIds.sort((chatId1: string, chatId2: string) => { return chatSummariesById.get(chatId2)!.order - chatSummariesById.get(chatId1)!.order; }); return { pinnedCount: sortedPinnedIds?.length || 0, orderedIds: [ ...(sortedPinnedIds || []), ...sortedNotPinnedIds, ], }; } function buildFolderUnreadCounters(folderId: number) { const { chatSummariesById, } = prepared; const { orderedIdsByFolderId: { [folderId]: orderedIds }, } = results; return orderedIds!.reduce((newUnreadCounters, chatId) => { const chatSummary = chatSummariesById.get(chatId); if (!chatSummary) { return newUnreadCounters; } if (chatSummary.isUnread) { newUnreadCounters.chatsCount++; if (chatSummary.unreadMentionsCount) { newUnreadCounters.notificationsCount += chatSummary.unreadMentionsCount; } if (!chatSummary.isMuted) { if (chatSummary.unreadCount) { newUnreadCounters.notificationsCount += chatSummary.unreadCount; } else if (!chatSummary.unreadMentionsCount) { newUnreadCounters.notificationsCount += 1; // Manually marked unread } } } return newUnreadCounters; }, { chatsCount: 0, notificationsCount: 0, }); } function buildInitials() { return { prevGlobal: { foldersById: {}, chatsById: {}, usersById: {}, notifySettings: {} as NotifySettings, notifyExceptions: {}, }, prepared: { folderSummariesById: {}, chatSummariesById: new Map(), folderIdsByChatId: {}, chatIdsByFolderId: {}, isOrderedListJustPatched: {}, }, results: { orderedIdsByFolderId: {}, pinnedCountByFolderId: {}, chatsCountByFolderId: {}, unreadCountersByFolderId: {}, }, callbacks: { orderedIdsByFolderId: {}, chatsCountByFolderId: createCallbackManager(), unreadCountersByFolderId: createCallbackManager(), }, }; } function reset() { const newInitials = buildInitials(); prevGlobal = newInitials.prevGlobal; prepared = newInitials.prepared; results = newInitials.results; callbacks = newInitials.callbacks; }