From b68ae94f3afe0e8569619adf912bf34e4590ad27 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:42:38 +0100 Subject: [PATCH] Forum: Support mentions and reactions (#6489) --- CLAUDE.md | 2 +- src/api/gramjs/apiBuilders/chats.ts | 92 +++-- src/api/gramjs/apiBuilders/forums.ts | 29 +- src/api/gramjs/methods/chats.ts | 83 +++-- src/api/gramjs/methods/forum.ts | 20 +- src/api/gramjs/methods/messages.ts | 40 +- src/api/gramjs/updates/mtpUpdateHandler.ts | 70 ++-- src/api/types/chats.ts | 10 - src/api/types/messages.ts | 19 +- src/api/types/updates.ts | 49 ++- src/assets/localization/fallback.strings | 5 +- .../common/ChatForumLastMessage.tsx | 44 ++- src/components/common/Composer.tsx | 10 +- src/components/common/GroupChatInfo.tsx | 2 +- src/components/common/PrivateChatInfo.tsx | 8 +- src/components/common/StickerSetModal.tsx | 2 +- .../common/profile/BusinessHours.tsx | 5 +- src/components/common/profile/ProfileInfo.tsx | 4 +- src/components/left/main/Archive.tsx | 10 +- src/components/left/main/Chat.tsx | 32 +- src/components/left/main/ChatBadge.tsx | 71 ++-- src/components/left/main/forum/ForumPanel.tsx | 18 +- src/components/left/main/forum/Topic.tsx | 39 +- .../left/main/hooks/useChatListEntry.tsx | 6 +- .../left/main/hooks/useTopicContextActions.ts | 15 +- .../left/search/LeftSearchResultChat.tsx | 12 +- src/components/middle/ContactGreeting.tsx | 38 +- .../middle/FloatingActionButtons.tsx | 87 ++--- src/components/middle/MessageList.tsx | 12 +- src/components/middle/MiddleColumn.tsx | 4 +- src/components/middle/MiddleHeader.tsx | 5 +- .../composer/ComposerEmbeddedMessage.tsx | 4 +- .../middle/composer/MessageInput.tsx | 3 +- .../middle/composer/WebPagePreview.tsx | 3 +- .../middle/hooks/useMessageObservers.ts | 4 +- .../middle/message/ActionMessage.tsx | 6 +- .../middle/message/ActionMessageText.tsx | 2 +- .../middle/message/CommentButton.tsx | 11 +- .../middle/message/ContextMenuContainer.tsx | 7 +- src/components/middle/message/Message.tsx | 24 +- .../middle/message/hooks/useInnerHandlers.ts | 2 +- .../quickPreview/QuickPreviewModalHeader.tsx | 14 +- .../transaction/StarsTransactionItem.tsx | 14 +- .../suggestMessage/SuggestMessageModal.tsx | 2 +- src/components/right/GifSearch.tsx | 3 +- src/global/actions/api/bots.ts | 2 +- src/global/actions/api/chats.ts | 162 ++++++--- src/global/actions/api/globalSearch.ts | 30 +- src/global/actions/api/messages.ts | 294 +++++++-------- src/global/actions/api/reactions.ts | 116 +++--- src/global/actions/api/sync.ts | 129 +++---- src/global/actions/apiUpdaters/chats.ts | 93 ++--- src/global/actions/apiUpdaters/messages.ts | 342 ++++++++---------- src/global/actions/apiUpdaters/misc.ts | 10 +- src/global/actions/ui/chats.ts | 3 +- src/global/actions/ui/messages.ts | 15 +- src/global/cache.ts | 26 +- src/global/helpers/chats.ts | 19 +- src/global/helpers/messages.ts | 29 +- src/global/helpers/payments.ts | 4 +- src/global/init.ts | 23 +- src/global/initialState.ts | 2 +- src/global/reducers/chats.ts | 94 +++-- src/global/reducers/messages.ts | 184 ++-------- src/global/reducers/reactions.ts | 68 +++- src/global/reducers/threads.ts | 241 ++++++++++++ src/global/reducers/topics.ts | 99 +++-- src/global/selectors/messages.ts | 270 +++----------- src/global/selectors/payments.ts | 4 +- src/global/selectors/settings.ts | 4 + src/global/selectors/threads.ts | 176 +++++++++ src/global/selectors/topics.ts | 13 + src/global/types/actions.ts | 27 +- src/hooks/data/useSelector.ts | 116 +++++- src/hooks/data/useSelectorSignal.ts | 57 --- src/hooks/useChatContextActions.ts | 102 ++++-- src/hooks/useSignalEffect.ts | 33 +- src/lib/teact/teact.ts | 2 +- src/lib/teact/teactn.tsx | 6 +- src/types/index.ts | 28 +- src/types/language.d.ts | 5 +- ...ropsShallowEqual.ts => areShallowEqual.ts} | 23 +- src/util/folderManager.ts | 27 +- src/util/iteratees.ts | 17 +- 84 files changed, 2189 insertions(+), 1648 deletions(-) create mode 100644 src/global/reducers/threads.ts create mode 100644 src/global/selectors/threads.ts delete mode 100644 src/hooks/data/useSelectorSignal.ts rename src/util/{arePropsShallowEqual.ts => areShallowEqual.ts} (60%) diff --git a/CLAUDE.md b/CLAUDE.md index 2bb63851e..cb75d5a60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -304,7 +304,7 @@ Global State is our single, app-wide store, similar to Redux or Zustand. All its ### 1. Accessing Global in Components * **Use** `withGlobal` (a `mapStateToProps` helper) to pull in state. -* **Avoid** the experimental `useSelector` API. +* There is an experimental `useSelector` API available. * **Use** `getGlobal` **only** inside hooks for one-off reads (it's non-reactive). ### 2. Performance diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 2946bc28c..9201bc459 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -1,28 +1,31 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { Entity } from '../../../lib/gramjs/types'; -import type { - ApiBotCommand, - ApiChat, - ApiChatAdminRights, - ApiChatBannedRights, - ApiChatFolder, - ApiChatInviteImporter, - ApiChatInviteInfo, - ApiChatlistExportedInvite, - ApiChatlistInvite, - ApiChatMember, - ApiChatReactions, - ApiExportedInvite, - ApiMissingInvitedUser, - ApiRestrictionReason, - ApiSendAsPeerId, - ApiSponsoredMessageReportResult, - ApiSponsoredPeer, - ApiStarsSubscriptionPricing, +import type { ThreadReadState } from '../../../types'; +import { + type ApiBotCommand, + type ApiChat, + type ApiChatAdminRights, + type ApiChatBannedRights, + type ApiChatFolder, + type ApiChatInviteImporter, + type ApiChatInviteInfo, + type ApiChatlistExportedInvite, + type ApiChatlistInvite, + type ApiChatMember, + type ApiChatReactions, + type ApiExportedInvite, + type ApiMissingInvitedUser, + type ApiRestrictionReason, + type ApiSendAsPeerId, + type ApiSponsoredMessageReportResult, + type ApiSponsoredPeer, + type ApiStarsSubscriptionPricing, + type ApiThreadInfo, + MAIN_THREAD_ID, } from '../../types'; -import { pickTruthy } from '../../../util/iteratees'; +import { omitUndefined, pickTruthy } from '../../../util/iteratees'; import { toJSNumber } from '../../../util/numbers'; import { getServerTimeOffset } from '../../../util/serverTime'; import { addPhotoToLocalDb, addUserToLocalDb } from '../helpers/localDb'; @@ -144,23 +147,15 @@ export function buildApiChatFromDialog( peerEntity: GramJs.TypeUser | GramJs.TypeChat, ): ApiChat { const { - peer, folderId, unreadMark, unreadCount, unreadMentionsCount, unreadReactionsCount, - readOutboxMaxId, readInboxMaxId, draft, viewForumAsMessages, + peer, folderId, viewForumAsMessages, } = dialog; return { id: getApiChatIdFromMtpPeer(peer), - ...(folderId && { folderId }), + folderId, type: getApiChatTypeFromPeerEntity(peerEntity), title: getApiChatTitleFromMtpPeer(peer, peerEntity), - lastReadOutboxMessageId: readOutboxMaxId, - lastReadInboxMessageId: readInboxMaxId, - unreadCount, - unreadMentionsCount, - unreadReactionsCount, - ...(unreadMark && { hasUnreadMark: true }), - ...(draft instanceof GramJs.DraftMessage && { draftDate: draft.date }), - ...(viewForumAsMessages && { isForumAsMessages: true }), + isForumAsMessages: viewForumAsMessages, ...buildApiChatFieldsFromPeerEntity(peerEntity), }; } @@ -705,3 +700,38 @@ export function buildApiSponsoredPeer(sponsoredPeer: GramJs.SponsoredPeer): ApiS sponsorInfo, }; } + +export function buildThreadReadState( + input: GramJs.Dialog | GramJs.MonoForumDialog | GramJs.ForumTopic | GramJs.messages.DiscussionMessage, +): ThreadReadState { + const { unreadCount, readInboxMaxId, readOutboxMaxId } = input; + const dialog = input instanceof GramJs.Dialog ? input : undefined; + const monoForumDialog = input instanceof GramJs.MonoForumDialog ? input : undefined; + const forumTopic = input instanceof GramJs.ForumTopic ? input : undefined; + + const { unreadReactionsCount } = dialog || monoForumDialog || forumTopic || {}; + const { unreadMentionsCount } = dialog || forumTopic || {}; + const { unreadMark } = dialog || monoForumDialog || {}; + + return omitUndefined({ + unreadCount, + lastReadInboxMessageId: readInboxMaxId, + lastReadOutboxMessageId: readOutboxMaxId, + unreadReactionsCount, + unreadMentionsCount, + hasUnreadMark: unreadMark, + }); +} + +export function buildApiThreadInfoFromDialog( + chatId: string, dialog: GramJs.Dialog | GramJs.SavedDialog, +): ApiThreadInfo { + const isSavedDialog = dialog instanceof GramJs.SavedDialog; + const { topMessage } = dialog; + return { + isCommentsInfo: false, + chatId, + threadId: isSavedDialog ? getApiChatIdFromMtpPeer(dialog.peer) : MAIN_THREAD_ID, + lastMessageId: topMessage, + }; +} diff --git a/src/api/gramjs/apiBuilders/forums.ts b/src/api/gramjs/apiBuilders/forums.ts index a3ffb2493..1a8a3a8f0 100644 --- a/src/api/gramjs/apiBuilders/forums.ts +++ b/src/api/gramjs/apiBuilders/forums.ts @@ -1,10 +1,11 @@ import { Api as GramJs } from '../../../lib/gramjs'; -import type { ApiTopic } from '../../types'; +import type { ApiTopic, ApiTopicWithState } from '../../types'; +import { buildThreadReadState } from './chats'; import { buildApiPeerNotifySettings, getApiChatIdFromMtpPeer } from './peers'; -export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | undefined { +function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | undefined { if (forumTopic instanceof GramJs.ForumTopicDeleted) { return undefined; } @@ -20,10 +21,6 @@ export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | und title, iconColor, iconEmojiId, - topMessage, - unreadCount, - unreadMentionsCount, - unreadReactionsCount, fromId, notifySettings, titleMissing, @@ -40,12 +37,24 @@ export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | und title, iconColor, iconEmojiId: iconEmojiId?.toString(), - lastMessageId: topMessage, - unreadCount, - unreadMentionsCount, - unreadReactionsCount, fromId: getApiChatIdFromMtpPeer(fromId), notifySettings: buildApiPeerNotifySettings(notifySettings), isTitleMissing: titleMissing, }; } + +export function buildApiTopicWithState(forumTopic: GramJs.TypeForumTopic): ApiTopicWithState | undefined { + if (forumTopic instanceof GramJs.ForumTopicDeleted) { + return undefined; + } + + const topic = buildApiTopic(forumTopic); + if (!topic) return undefined; + const isMin = topic.isMin; + + return { + topic, + readState: !isMin ? buildThreadReadState(forumTopic) : undefined, + lastMessageId: !isMin ? forumTopic.topMessage : undefined, + }; +} diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 741a1d562..9e41fc095 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -1,24 +1,26 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { RPCError } from '../../../lib/gramjs/errors'; -import type { ChatListType } from '../../../types'; -import type { - ApiChat, - ApiChatAdminRights, - ApiChatBannedRights, - ApiChatFolder, - ApiChatFullInfo, - ApiChatReactions, - ApiDraft, - ApiGroupCall, - ApiMessage, - ApiMissingInvitedUser, - ApiPeer, - ApiPeerNotifySettings, - ApiPhoto, - ApiProfileTab, - ApiUser, - ApiUserStatus, +import type { ChatListType, ThreadReadState } from '../../../types'; +import { + type ApiChat, + type ApiChatAdminRights, + type ApiChatBannedRights, + type ApiChatFolder, + type ApiChatFullInfo, + type ApiChatReactions, + type ApiDraft, + type ApiGroupCall, + type ApiMessage, + type ApiMissingInvitedUser, + type ApiPeer, + type ApiPeerNotifySettings, + type ApiPhoto, + type ApiProfileTab, + type ApiThreadInfo, + type ApiUser, + type ApiUserStatus, + MAIN_THREAD_ID, } from '../../types'; import { @@ -43,8 +45,10 @@ import { buildApiChatReactions, buildApiMissingInvitedUser, buildApiSponsoredPeer, + buildApiThreadInfoFromDialog, buildChatMember, buildChatMembers, + buildThreadReadState, getPeerKey, } from '../apiBuilders/chats'; import { buildApiPhoto } from '../apiBuilders/common'; @@ -101,6 +105,8 @@ type ChatListData = { users: ApiUser[]; userStatusesById: Record; draftsById: Record; + threadReadStatesById?: Record; + threadInfos: ApiThreadInfo[]; orderedPinnedIds: string[] | undefined; totalChatCount: number; messages: ApiMessage[]; @@ -161,6 +167,8 @@ export async function fetchChats({ const chats: ApiChat[] = []; const draftsById: Record = {}; const notifyExceptionById: Record = {}; + const threadReadStatesById: Record = {}; + const threadInfos: ApiThreadInfo[] = []; const dialogs = (resultPinned?.dialogs || []).concat(result.dialogs); @@ -216,6 +224,12 @@ export async function fetchChats({ draftsById[chat.id] = draft; } } + + const readState = buildThreadReadState(dialog); + threadReadStatesById[chat.id] = readState; + + const threadInfo = buildApiThreadInfoFromDialog(chat.id, dialog); + threadInfos.push(threadInfo); }); const chatIds = chats.map((chat) => chat.id); @@ -251,26 +265,33 @@ export async function fetchChats({ nextOffsetId, nextOffsetPeerId, nextOffsetDate, + threadReadStatesById, + threadInfos, }; } export async function fetchSavedChats({ + parentPeer, limit, offsetDate, offsetPeer, offsetId, withPinned, }: { + parentPeer: ApiPeer; limit: number; offsetDate?: number; offsetPeer?: ApiPeer; offsetId?: number; withPinned?: boolean; }): Promise { + const isMonoforum = 'title' in parentPeer; + const inputParentPeer = isMonoforum ? buildInputPeer(parentPeer.id, parentPeer.accessHash) : undefined; const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty(); const result = await invokeRequest(new GramJs.messages.GetSavedDialogs({ offsetPeer: peer, offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT, + parentPeer: inputParentPeer, limit, offsetDate: offsetDate ?? DEFAULT_PRIMITIVES.INT, hash: DEFAULT_PRIMITIVES.BIGINT, @@ -301,6 +322,7 @@ export async function fetchSavedChats({ const chatIds: string[] = []; const orderedPinnedIds: string[] = []; const lastMessageByChatId: Record = {}; + const threadInfos: ApiThreadInfo[] = []; const chats: ApiChat[] = []; @@ -321,6 +343,9 @@ export async function fetchSavedChats({ lastMessageByChatId[chatId] = dialog.topMessage; chats.push(chat); + + const threadInfo = buildApiThreadInfoFromDialog(parentPeer.id, dialog); + threadInfos.push(threadInfo); }); const users = result.users.map(buildApiUser).filter(Boolean); @@ -355,6 +380,7 @@ export async function fetchSavedChats({ nextOffsetId, nextOffsetPeerId, nextOffsetDate, + threadInfos, }; } @@ -496,6 +522,14 @@ export async function requestChatUpdate({ const chatUpdate = buildApiChatFromDialog(dialog, peerEntity); + const readState = buildThreadReadState(dialog); + sendApiUpdate({ + '@type': 'updateThreadReadState', + chatId: id, + threadId: MAIN_THREAD_ID, + readState, + }); + sendApiUpdate({ '@type': 'updateChat', id, @@ -1195,7 +1229,7 @@ export function toggleDialogFilterTags(isEnabled: boolean) { export async function toggleDialogUnread({ chat, hasUnreadMark, }: { - chat: ApiChat; hasUnreadMark: boolean | undefined; + chat: ApiChat; hasUnreadMark?: true; }) { const { id, accessHash } = chat; @@ -1203,14 +1237,17 @@ export async function toggleDialogUnread({ peer: new GramJs.InputDialogPeer({ peer: buildInputPeer(id, accessHash), }), - unread: hasUnreadMark || undefined, + unread: hasUnreadMark, })); if (isActionSuccessful) { sendApiUpdate({ - '@type': 'updateChat', - id: chat.id, - chat: { hasUnreadMark }, + '@type': 'updateThreadReadState', + chatId: chat.id, + threadId: MAIN_THREAD_ID, + readState: { + hasUnreadMark, + }, }); } } diff --git a/src/api/gramjs/methods/forum.ts b/src/api/gramjs/methods/forum.ts index 5cd3660c6..363473e48 100644 --- a/src/api/gramjs/methods/forum.ts +++ b/src/api/gramjs/methods/forum.ts @@ -1,7 +1,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { generateRandomBigInt } from '../../../lib/gramjs/Helpers'; -import type { ApiMessage, ApiTopic } from '../../types'; +import type { ApiMessage, ApiTopicWithState } from '../../types'; import type { ApiChat, ApiDraft, @@ -9,7 +9,7 @@ import type { } from '../../types/chats'; import { GENERAL_TOPIC_ID, TOPICS_SLICE } from '../../../config'; -import { buildApiTopic } from '../apiBuilders/forums'; +import { buildApiTopicWithState } from '../apiBuilders/forums'; import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages'; import { buildInputPeer, DEFAULT_PRIMITIVES } from '../gramjsBuilders'; import { processAffectedHistory } from '../updates/updateManager'; @@ -57,12 +57,11 @@ export async function fetchTopics({ offsetDate?: number; limit?: number; }): Promise<{ - topics: ApiTopic[]; + topics: ApiTopicWithState[]; messages: ApiMessage[]; count: number; shouldOrderByCreateDate?: boolean; draftsById: Record; - readInboxMessageIdByTopicId: Record; } | undefined> { const { id, accessHash } = chat; @@ -79,7 +78,7 @@ export async function fetchTopics({ const { orderByCreateDate } = result; - const topics = result.topics.map(buildApiTopic).filter(Boolean); + const topics = result.topics.map(buildApiTopicWithState).filter(Boolean); const count = result.count === 0 ? topics.length : result.count; // Sometimes count is 0 in result, but we have topics const messages = result.messages.map(buildApiMessage).filter(Boolean); const draftsById = result.topics.reduce((acc, topic) => { @@ -88,12 +87,6 @@ export async function fetchTopics({ } return acc; }, {} as Record>); - const readInboxMessageIdByTopicId = result.topics.reduce((acc, topic) => { - if (topic instanceof GramJs.ForumTopic && topic.readInboxMaxId) { - acc[topic.id] = topic.readInboxMaxId; - } - return acc; - }, {} as Record); return { topics, @@ -102,7 +95,6 @@ export async function fetchTopics({ count: count + 1, shouldOrderByCreateDate: orderByCreateDate, draftsById, - readInboxMessageIdByTopicId, }; } @@ -112,7 +104,7 @@ export async function fetchTopicById({ chat: ApiChat; topicId: number; }): Promise<{ - topic: ApiTopic; + topic: ApiTopicWithState; messages: ApiMessage[]; } | undefined> { const { id, accessHash } = chat; @@ -129,7 +121,7 @@ export async function fetchTopicById({ const messages = result.messages.map(buildApiMessage).filter(Boolean); return { - topic: buildApiTopic(result.topics[0])!, + topic: buildApiTopicWithState(result.topics[0])!, messages, }; } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index edc502e59..321fd7e3b 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -27,6 +27,7 @@ import type { ApiSearchPostsFlood, ApiSendMessageAction, ApiTodoItem, + ApiTopicWithState, ApiUser, ApiUserStatus, ApiWebPage, @@ -57,8 +58,10 @@ import { buildApiChatFromPreview, buildApiSendAsPeerId, buildApiSponsoredMessageReportResult, + buildThreadReadState, } from '../apiBuilders/chats'; import { buildApiFormattedText } from '../apiBuilders/common'; +import { buildApiTopicWithState } from '../apiBuilders/forums'; import { buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia, buildWebPageFromMedia, @@ -126,6 +129,7 @@ type TranslateTextParams = ({ type SearchResults = { messages: ApiMessage[]; + topics: ApiTopicWithState[]; userStatusesById: Record; totalCount: number; nextOffsetRate?: number; @@ -196,13 +200,15 @@ export async function fetchMessages({ const messages = result.messages.map(buildApiMessage).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean); const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); - const count = !(result instanceof GramJs.messages.Messages) && result.count; + const count = !(result instanceof GramJs.messages.Messages) ? result.count : undefined; + const topics = result.topics.map(buildApiTopicWithState).filter(Boolean); return { messages, users, chats, count, + topics, }; } @@ -371,6 +377,7 @@ export function sendApiMessage( message: { sendingState: 'messageSendingStatePending', }, + isFull: false, }); }, FAST_SEND_TIMEOUT); @@ -740,7 +747,7 @@ export async function editMessage({ }), }; - const messageUpdate: Partial = { + const messageUpdate: ApiMessage = { ...message, content: newContent, isInvertedMedia, @@ -751,6 +758,7 @@ export async function editMessage({ id: message.id, chatId: chat.id, message: messageUpdate, + isFull: true, }); try { @@ -794,6 +802,7 @@ export async function editMessage({ id: message.id, chatId: chat.id, message, + isFull: true, }); } } @@ -818,7 +827,7 @@ export async function editTodo({ }, }; - const messageUpdate: Partial = { + const messageUpdate: ApiMessage = { ...message, content: newContent, }; @@ -828,6 +837,7 @@ export async function editTodo({ id: message.id, chatId: chat.id, message: messageUpdate, + isFull: true, }); try { @@ -858,6 +868,7 @@ export async function editTodo({ id: message.id, chatId: chat.id, message, + isFull: true, }); } } @@ -1447,17 +1458,14 @@ export async function fetchDiscussionMessage({ if (!threadId) return undefined; - const { - unreadCount, maxId, readInboxMaxId, readOutboxMaxId, - } = result; + const { maxId } = result; + const threadReadState = buildThreadReadState(result); return { messages, topMessages, - unreadCount, threadId, - lastReadInboxMessageId: readInboxMaxId, - lastReadOutboxMessageId: readOutboxMaxId, + threadReadState, lastMessageId: maxId, chatId: topMessages[0]?.chatId, firstMessageId: replies.messages[0]?.id, @@ -1554,6 +1562,7 @@ export async function searchMessagesInChat({ const userStatusesById = buildApiUserStatuses(result.users); const messages = result.messages.map(buildApiMessage).filter(Boolean); + const topics = result.topics.map(buildApiTopicWithState).filter(Boolean); let totalCount = messages.length; let nextOffsetId: number | undefined; @@ -1568,6 +1577,7 @@ export async function searchMessagesInChat({ return { userStatusesById, messages, + topics, totalCount, nextOffsetId, }; @@ -1649,6 +1659,7 @@ export async function searchMessagesGlobal({ const userStatusesById = buildApiUserStatuses(result.users); const messages = result.messages.map(buildApiMessage).filter(Boolean); + const topics = result.topics.map(buildApiTopicWithState).filter(Boolean); let totalCount = messages.length; if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) { @@ -1664,6 +1675,7 @@ export async function searchMessagesGlobal({ return { messages, + topics, userStatusesById, totalCount, nextOffsetRate, @@ -1706,6 +1718,7 @@ export async function searchPublicPosts({ const userStatusesById = buildApiUserStatuses(result.users); const messages = result.messages.map(buildApiMessage).filter(Boolean); + const topics = result.topics.map(buildApiTopicWithState).filter(Boolean); let totalCount = messages.length; if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) { @@ -1725,6 +1738,7 @@ export async function searchPublicPosts({ return { messages, + topics, userStatusesById, totalCount, nextOffsetRate, @@ -2248,10 +2262,14 @@ export async function fetchUnreadMentions({ return undefined; } + const totalCount = 'count' in result ? result.count : result.messages.length; const messages = result.messages.map(buildApiMessage).filter(Boolean); + const topics = result.topics.map(buildApiTopicWithState).filter(Boolean); return { + totalCount, messages, + topics, }; } @@ -2283,10 +2301,14 @@ export async function fetchUnreadReactions({ return undefined; } + const totalCount = 'count' in result ? result.count : result.messages.length; const messages = result.messages.map(buildApiMessage).filter(Boolean); + const topics = result.topics.map(buildApiTopicWithState).filter(Boolean); return { + totalCount, messages, + topics, }; } diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index 3d9dc6fc1..5e7281cd3 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -2,10 +2,11 @@ import { Api as GramJs, type Update } from '../../../lib/gramjs'; import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network'; import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; -import type { - ApiMessage, ApiPoll, ApiStory, ApiStorySkipped, - ApiUpdateConnectionStateType, - ApiWebPage, +import { + type ApiMessage, type ApiPoll, type ApiStory, type ApiStorySkipped, + type ApiUpdateConnectionStateType, + type ApiWebPage, + MAIN_THREAD_ID, } from '../../types'; import { DEBUG, GENERAL_TOPIC_ID } from '../../../config'; @@ -176,6 +177,7 @@ export function updater(update: Update) { poll, webPage, isFromNew: true, + isFull: true, }); } else { sendApiUpdate({ @@ -187,6 +189,7 @@ export function updater(update: Update) { poll, webPage, isFromNew: true, + isFull: true, }); } @@ -352,7 +355,7 @@ export function updater(update: Update) { processMessageAndUpdateThreadInfo(mtpMessage); // Workaround for a weird server behavior when own message is marked as incoming - const message = omit(buildApiMessage(mtpMessage)!, ['isOutgoing']); + const message = omit(buildApiMessage(mtpMessage)!, ['isOutgoing']) as ApiMessage; const poll = mtpMessage instanceof GramJs.Message && mtpMessage.media ? buildPollFromMedia(mtpMessage.media) : undefined; @@ -367,11 +370,13 @@ export function updater(update: Update) { message, poll, webPage, + isFull: true, }); } else if (update instanceof GramJs.UpdateMessageReactions) { sendApiUpdate({ '@type': 'updateMessageReactions', id: update.msgId, + threadId: update.topMsgId, chatId: getApiChatIdFromMtpPeer(update.peer), reactions: buildMessageReactions(update.reactions), }); @@ -508,51 +513,57 @@ export function updater(update: Update) { // Chats } else if (update instanceof GramJs.UpdateReadHistoryInbox) { sendApiUpdate({ - '@type': 'updateChatInbox', - id: getApiChatIdFromMtpPeer(update.peer), - lastReadInboxMessageId: update.maxId, - unreadCount: update.stillUnreadCount, - threadId: update.topMsgId, + '@type': 'updateThreadReadState', + chatId: getApiChatIdFromMtpPeer(update.peer), + threadId: update.topMsgId || MAIN_THREAD_ID, + readState: { + lastReadInboxMessageId: update.maxId, + unreadCount: update.stillUnreadCount, + }, }); } else if (update instanceof GramJs.UpdateReadHistoryOutbox) { sendApiUpdate({ - '@type': 'updateChat', - id: getApiChatIdFromMtpPeer(update.peer), - chat: { + '@type': 'updateThreadReadState', + chatId: getApiChatIdFromMtpPeer(update.peer), + threadId: MAIN_THREAD_ID, + readState: { lastReadOutboxMessageId: update.maxId, }, }); } else if (update instanceof GramJs.UpdateReadChannelInbox) { sendApiUpdate({ - '@type': 'updateChat', - id: buildApiPeerId(update.channelId, 'channel'), - chat: { + '@type': 'updateThreadReadState', + chatId: buildApiPeerId(update.channelId, 'channel'), + threadId: MAIN_THREAD_ID, + readState: { lastReadInboxMessageId: update.maxId, unreadCount: update.stillUnreadCount, }, }); } else if (update instanceof GramJs.UpdateReadChannelOutbox) { sendApiUpdate({ - '@type': 'updateChat', - id: buildApiPeerId(update.channelId, 'channel'), - chat: { + '@type': 'updateThreadReadState', + chatId: buildApiPeerId(update.channelId, 'channel'), + threadId: MAIN_THREAD_ID, + readState: { lastReadOutboxMessageId: update.maxId, }, }); } else if (update instanceof GramJs.UpdateReadChannelDiscussionInbox) { sendApiUpdate({ - '@type': 'updateThreadInfo', - threadInfo: { - chatId: buildApiPeerId(update.channelId, 'channel'), - threadId: update.topMsgId, + '@type': 'updateThreadReadState', + chatId: buildApiPeerId(update.channelId, 'channel'), + threadId: update.topMsgId, + readState: { lastReadInboxMessageId: update.readMaxId, }, }); } else if (update instanceof GramJs.UpdateReadChannelDiscussionOutbox) { sendApiUpdate({ - '@type': 'updateChat', - id: buildApiPeerId(update.channelId, 'channel'), - chat: { + '@type': 'updateThreadReadState', + chatId: buildApiPeerId(update.channelId, 'channel'), + threadId: update.topMsgId, + readState: { lastReadOutboxMessageId: update.readMaxId, }, }); @@ -752,9 +763,10 @@ export function updater(update: Update) { && update.peer instanceof GramJs.DialogPeer ) { sendApiUpdate({ - '@type': 'updateChat', - id: getApiChatIdFromMtpPeer(update.peer.peer), - chat: { + '@type': 'updateThreadReadState', + chatId: getApiChatIdFromMtpPeer(update.peer.peer), + threadId: MAIN_THREAD_ID, + readState: { hasUnreadMark: update.unread, }, }); diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index c6261edb9..4d55efa84 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -28,12 +28,6 @@ export interface ApiChat { folderId?: number; type: ApiChatType; title: string; - hasUnreadMark?: boolean; - lastReadOutboxMessageId?: number; - lastReadInboxMessageId?: number; - unreadCount?: number; - unreadMentionsCount?: number; - unreadReactionsCount?: number; isVerified?: true; areSignaturesShown?: boolean; areProfilesShown?: boolean; @@ -48,7 +42,6 @@ export interface ApiChat { membersCount?: number; creationDate?: number; isSupport?: true; - draftDate?: number; isProtected?: boolean; fakeType?: ApiFakeType; color?: ApiTypePeerColor; @@ -93,9 +86,6 @@ export interface ApiChat { sendPaidReactionsAsPeerIds?: ApiSendAsPeerId[]; sendPaidReactionsPeer?: ApiSendAsPeerId; - unreadReactions?: number[]; - unreadMentions?: number[]; - // Stories areStoriesHidden?: boolean; hasStories?: boolean; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 2d24244e3..93d13c34d 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1,4 +1,4 @@ -import type { ThreadId, WebPageMediaSize } from '../../types'; +import type { ThreadId, ThreadReadState, WebPageMediaSize } from '../../types'; import type { ApiBotInlineMediaResult, ApiBotInlineResult, @@ -813,11 +813,10 @@ export type PaidReactionPrivacyPeer = { peerId: string; }; -interface ApiBaseThreadInfo { +export interface ApiBaseThreadInfo { chatId: string; - messagesCount: number; + messagesCount?: number; lastMessageId?: number; - lastReadInboxMessageId?: number; recentReplierIds?: string[]; } @@ -1085,22 +1084,22 @@ export interface ApiTopic { isPinned?: boolean; isHidden?: boolean; isOwner?: boolean; - - // TODO[forums] https://github.com/telegramdesktop/tdesktop/blob/1aece79a471d99a8b63d826b1bce1f36a04d7293/Telegram/SourceFiles/data/data_forum_topic.cpp#L318 isMin?: boolean; date: number; title: string; iconColor: number; iconEmojiId?: string; - lastMessageId: number; - unreadCount: number; - unreadMentionsCount: number; - unreadReactionsCount: number; fromId: string; notifySettings: ApiPeerNotifySettings; isTitleMissing?: boolean; } +export interface ApiTopicWithState { + topic: ApiTopic; + readState?: ThreadReadState; + lastMessageId?: number; +} + export const MAIN_THREAD_ID = -1; // `Symbol` can not be transferred from worker diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 04b81e5f7..2d8c01ede 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -6,7 +6,7 @@ import type { VideoRotation, VideoState, } from '../../lib/secret-sauce'; -import type { ThreadId } from '../../types'; +import type { ThreadId, ThreadReadState } from '../../types'; import type { RegularLangFnParameters } from '../../util/localization'; import type { ApiBotCommand, ApiBotMenuButton } from './bots'; import type { @@ -122,6 +122,7 @@ export type ApiUpdateChat = { '@type': 'updateChat'; id: string; chat: Partial; + readState?: Partial; noTopChatsRequest?: boolean; }; @@ -141,14 +142,6 @@ export type ApiUpdateChatLeave = { id: string; }; -export type ApiUpdateChatInbox = { - '@type': 'updateChatInbox'; - id: string; - threadId?: ThreadId; - lastReadInboxMessageId: number; - unreadCount: number; -}; - export type ApiUpdateChatTypingStatus = { '@type': 'updateChatTypingStatus'; id: string; @@ -245,7 +238,7 @@ export type ApiUpdateNewMessage = { '@type': 'newMessage'; chatId: string; id: number; - message: Partial; + message: ApiMessage; shouldForceReply?: boolean; wasDrafted?: boolean; poll?: ApiPoll; @@ -256,22 +249,36 @@ export type ApiUpdateMessage = { '@type': 'updateMessage'; chatId: string; id: number; - message: Partial; poll?: ApiPoll; webPage?: ApiWebPage; shouldForceReply?: boolean; isFromNew?: true; -}; +} & ( + { + isFull: true; + message: ApiMessage; + } | { + isFull?: false; + message: Partial; + } +); export type ApiUpdateScheduledMessage = { '@type': 'updateScheduledMessage'; chatId: string; id: number; - message: Partial; poll?: ApiPoll; webPage?: ApiWebPage; isFromNew?: true; -}; +} & ( + { + isFull: true; + message: ApiMessage; + } | { + isFull?: false; + message: Partial; + } +); export type ApiUpdateQuickReplyMessage = { '@type': 'updateQuickReplyMessage'; @@ -306,7 +313,14 @@ export type ApiUpdatePinnedMessageIds = { export type ApiUpdateThreadInfo = { '@type': 'updateThreadInfo'; - threadInfo: Partial; + threadInfo: ApiThreadInfo; +}; + +export type ApiUpdateThreadReadState = { + '@type': 'updateThreadReadState'; + chatId: string; + threadId: ThreadId; + readState: Partial; }; export type ApiUpdateScheduledMessageSendSucceeded = { @@ -424,6 +438,7 @@ export type ApiUpdateMessageReactions = { '@type': 'updateMessageReactions'; id: number; chatId: string; + threadId?: ThreadId; reactions: ApiReactions; }; @@ -898,7 +913,7 @@ export type ApiUpdateWebPage = { export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | - ApiUpdateChat | ApiUpdateChatInbox | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds | + ApiUpdateChat | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds | ApiUpdateChatMembers | ApiUpdateChatJoin | ApiUpdateChatLeave | ApiUpdateChatPinned | ApiUpdatePinnedMessageIds | ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders | ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdatePasskeyOption | @@ -907,7 +922,7 @@ export type ApiUpdate = ( ApiUpdateServiceNotification | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateVideoProcessingPending | ApiUpdatePeerSettings | ApiUpdateUserAlreadyAuthorized | ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateScheduledMessageSendFailed | - ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | + ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | ApiUpdateThreadReadState | ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder | ApiUpdateRecentStickers | ApiUpdateSavedGifs | ApiUpdateNewScheduledMessage | ApiUpdateMoveStickerSetToTop | ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | ApiUpdateStarPaymentStateCompleted | diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 02f66ed14..fd7201a15 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1234,8 +1234,8 @@ "ChatListPinToTop" = "Pin to Top"; "ChatListOpenInNewWindow" = "Open in New Window"; "ChatListOpenInNewTab" = "Open in New Tab"; -"ChatListContextMaskAsRead" = "Mark as Read"; -"ChatListContextMaskAsUnread" = "Mark as Unread"; +"ChatListContextMarkAsRead" = "Mark as Read"; +"ChatListContextMarkAsUnread" = "Mark as Unread"; "ChatListContextAddToFolder" = "Add to Folder"; "Unarchive" = "Unarchive"; "Archive" = "Archive"; @@ -2558,6 +2558,7 @@ "BotReadTextFromClipboardTitle" = "Clipboard Access"; "BotReadTextFromClipboardDescription" = "{bot} wants to read the contents of your clipboard. Do you want to continue?"; "BotReadTextFromClipboardConfirm" = "Allow"; +"ChatInfoForumTopic" = "Topic"; "DiceToast" = "Send a {emoji} emoji to try your luck."; "DiceToastSend" = "Send"; "ChatTypePrivate" = "Private Chat"; diff --git a/src/components/common/ChatForumLastMessage.tsx b/src/components/common/ChatForumLastMessage.tsx index 0fe3b2e60..8204a4c14 100644 --- a/src/components/common/ChatForumLastMessage.tsx +++ b/src/components/common/ChatForumLastMessage.tsx @@ -1,6 +1,7 @@ import type { TeactNode } from '../../lib/teact/teact'; import { memo, + useCallback, useEffect, useMemo, useRef, @@ -8,14 +9,19 @@ import { } from '../../lib/teact/teact'; import { getActions } from '../../global'; -import type { ApiChat, ApiTopic } from '../../api/types'; +import type { ApiChat } from '../../api/types'; +import type { GlobalState } from '../../global/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { getOrderedTopics } from '../../global/helpers'; +import { selectTopic } from '../../global/selectors'; +import { selectThread } from '../../global/selectors/threads'; import buildClassName from '../../util/buildClassName'; +import { buildCollectionByCallback, mapTruthyValues } from '../../util/iteratees'; import { REM } from './helpers/mediaDimensions'; import renderText from './helpers/renderText'; +import { useShallowSelector } from '../../hooks/data/useSelector'; import { getIsMobile } from '../../hooks/useAppLayout'; import { useFastClick } from '../../hooks/useFastClick'; import useLang from '../../hooks/useLang'; @@ -26,7 +32,7 @@ import styles from './ChatForumLastMessage.module.scss'; type OwnProps = { chat: ApiChat; - topics?: Record; + topicIds?: number[]; hasTags?: boolean; renderLastMessage: () => TeactNode | undefined; observeIntersection?: ObserveFn; @@ -37,7 +43,7 @@ const MAX_TOPICS = 3; const ChatForumLastMessage = ({ chat, - topics, + topicIds, hasTags, renderLastMessage, observeIntersection, @@ -49,13 +55,29 @@ const ChatForumLastMessage = ({ const lang = useLang(); + const topicsThreadSelector = useCallback((global: GlobalState) => { + return buildCollectionByCallback(topicIds || [], (tId) => ( + [tId, selectThread(global, chat.id, tId)] + )); + }, [chat.id, topicIds]); + const topicsThreads = useShallowSelector(topicsThreadSelector); + + const topicsSelector = useCallback((global: GlobalState) => { + return topicIds?.map((tId) => selectTopic(global, chat.id, tId)).filter(Boolean); + }, [chat.id, topicIds]); + const topics = useShallowSelector(topicsSelector); + const [lastActiveTopic, ...otherTopics] = useMemo(() => { if (!topics) { return []; } - return getOrderedTopics(Object.values(topics), undefined, true).slice(0, MAX_TOPICS); - }, [topics]); + const topicsThreadInfos = mapTruthyValues(topicsThreads, (t) => t?.threadInfo); + + return getOrderedTopics(topics, topicsThreadInfos, undefined, true).slice(0, MAX_TOPICS); + }, [topics, topicsThreads]); + + const lastActiveTopicReadState = lastActiveTopic ? topicsThreads[lastActiveTopic.id]?.readState : undefined; const [isReversedCorner, setIsReversedCorner] = useState(false); const [overwrittenWidth, setOverwrittenWidth] = useState(undefined); @@ -64,7 +86,8 @@ const ChatForumLastMessage = ({ handleClick: handleOpenTopicClick, handleMouseDown: handleOpenTopicMouseDown, } = useFastClick((e: React.MouseEvent) => { - if (lastActiveTopic.unreadCount === 0 || (chat.isForumAsMessages && !chat.isBotForum)) return; + if (!lastActiveTopic) return; + if (lastActiveTopicReadState?.unreadCount === 0 || (chat.isForumAsMessages && !chat.isBotForum)) return; e.stopPropagation(); e.preventDefault(); @@ -111,7 +134,7 @@ const ChatForumLastMessage = ({
(
@@ -159,7 +182,10 @@ const ChatForumLastMessage = ({ ) }
{messagesCount !== undefined - ? lang('Messages', { count: messagesCount }, { pluralValue: messagesCount }) - : lang('ChatInfoNoMessages')} + ? (messagesCount > 0 + ? lang('Messages', { count: messagesCount }, { pluralValue: messagesCount }) + : lang('ChatInfoNoMessages') + ) : lang('ChatInfoForumTopic')} ); diff --git a/src/components/common/StickerSetModal.tsx b/src/components/common/StickerSetModal.tsx index a38c66d4a..4e7c64692 100644 --- a/src/components/common/StickerSetModal.tsx +++ b/src/components/common/StickerSetModal.tsx @@ -20,9 +20,9 @@ import { selectPeerPaidMessagesStars, selectShouldSchedule, selectStickerSet, - selectThreadInfo, selectTopic, } from '../../global/selectors'; +import { selectThreadInfo } from '../../global/selectors/threads'; import buildClassName from '../../util/buildClassName'; import { copyTextToClipboard } from '../../util/clipboard'; import renderText from './helpers/renderText'; diff --git a/src/components/common/profile/BusinessHours.tsx b/src/components/common/profile/BusinessHours.tsx index aabc94384..13cab6285 100644 --- a/src/components/common/profile/BusinessHours.tsx +++ b/src/components/common/profile/BusinessHours.tsx @@ -4,6 +4,7 @@ import { import type { ApiBusinessWorkHours } from '../../../api/types'; +import { selectTimezones } from '../../../global/selectors'; import { VTT_PROFILE_BUSINESS_HOURS, VTT_PROFILE_BUSINESS_HOURS_COLLAPSE, @@ -18,7 +19,7 @@ import { import { useViewTransition } from '../../../hooks/animations/useViewTransition'; import { useVtn } from '../../../hooks/animations/useVtn'; -import useSelectorSignal from '../../../hooks/data/useSelectorSignal'; +import { useSelectorSignal } from '../../../hooks/data/useSelector'; import useInterval from '../../../hooks/schedulers/useInterval'; import useDerivedState from '../../../hooks/useDerivedState'; import useFlag from '../../../hooks/useFlag'; @@ -52,7 +53,7 @@ const BusinessHours = ({ useInterval(forceUpdate, 60 * 1000); - const timezoneSignal = useSelectorSignal((global) => global.timezones?.byId); + const timezoneSignal = useSelectorSignal(selectTimezones); const timezones = useDerivedState(timezoneSignal, [timezoneSignal]); const timezoneMinuteDifference = useMemo(() => { if (!timezones) return 0; diff --git a/src/components/common/profile/ProfileInfo.tsx b/src/components/common/profile/ProfileInfo.tsx index 91d40977a..e774f49f2 100644 --- a/src/components/common/profile/ProfileInfo.tsx +++ b/src/components/common/profile/ProfileInfo.tsx @@ -33,13 +33,13 @@ import { selectPeerSavedGifts, selectTabState, selectTheme, - selectThreadMessagesCount, selectTopic, selectUser, selectUserFullInfo, selectUserStatus, } from '../../../global/selectors/index'; import { selectSharedSettings } from '../../../global/selectors/sharedState'; +import { selectThreadMessagesCount } from '../../../global/selectors/threads.ts'; import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import buildStyle from '../../../util/buildStyle'; @@ -654,7 +654,7 @@ export default memo(withGlobal( emojiStatus, profilePhotos, topic, - messagesCount: topic ? selectThreadMessagesCount(global, peerId, currentTopicId!) : undefined, + messagesCount: topic ? selectThreadMessagesCount(global, peerId, topic.id) : undefined, profileColorOption: profileColor, theme, isPlain: !hasBackground, diff --git a/src/components/left/main/Archive.tsx b/src/components/left/main/Archive.tsx index 05b9c00c7..11e723894 100644 --- a/src/components/left/main/Archive.tsx +++ b/src/components/left/main/Archive.tsx @@ -3,10 +3,13 @@ import { getActions, getGlobal } from '../../../global'; import type { GlobalState } from '../../../global/types'; import type { CustomPeer } from '../../../types'; +import { MAIN_THREAD_ID } from '../../../api/types'; import { ANIMATION_LEVEL_MIN, ARCHIVED_FOLDER_ID } from '../../../config'; import { getChatTitle } from '../../../global/helpers'; +import { selectChat } from '../../../global/selectors'; import { selectAnimationLevel } from '../../../global/selectors/sharedState'; +import { selectThreadReadState } from '../../../global/selectors/threads'; import buildClassName from '../../../util/buildClassName'; import buildStyle from '../../../util/buildStyle'; import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners'; @@ -95,11 +98,12 @@ const Archive = ({ const previewItems = useMemo(() => { if (!orderedChatIds?.length) return lang('Loading'); - const chatsById = getGlobal().chats.byId; + const global = getGlobal(); return orderedChatIds.slice(0, PREVIEW_SLICE).map((chatId, i, arr) => { const isLast = i === arr.length - 1; - const chat = chatsById[chatId]; + const chat = selectChat(global, chatId); + const readState = selectThreadReadState(global, chatId, MAIN_THREAD_ID); if (!chat) { return undefined; } @@ -108,7 +112,7 @@ const Archive = ({ return ( <> - + {renderText(title)} {isLast ? '' : ', '} diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index d4d8b418f..78cc9598a 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -33,7 +33,6 @@ import { selectChatLastMessageId, selectChatMessage, selectCurrentMessageList, - selectDraft, selectIsCurrentUserFrozen, selectIsCurrentUserPremium, selectIsForumPanelClosed, @@ -46,19 +45,19 @@ import { selectPeerStory, selectSender, selectTabState, - selectThreadParam, selectTopicFromMessage, selectTopicsInfo, selectUser, selectUserStatus, } from '../../../global/selectors'; +import { selectDraft, selectThreadLocalStateParam } from '../../../global/selectors/threads'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import { isUserId } from '../../../util/entities/ids'; import { getChatFolderIds } from '../../../util/folderManager'; import { createLocationHash } from '../../../util/routing'; -import useSelectorSignal from '../../../hooks/data/useSelectorSignal'; +import { useSelectorSignal } from '../../../hooks/data/useSelector'; import useAppLayout from '../../../hooks/useAppLayout'; import useChatContextActions from '../../../hooks/useChatContextActions'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; @@ -96,11 +95,11 @@ type OwnProps = { previewMessageId?: number; className?: string; withTags?: boolean; + isFoldersSidebarShown?: boolean; observeIntersection?: ObserveFn; onDragEnter?: (chatId: string) => void; onDragLeave?: NoneToVoidFunction; onReorderAnimationEnd?: NoneToVoidFunction; - isFoldersSidebarShown?: boolean; }; type StateProps = { @@ -108,7 +107,6 @@ type StateProps = { monoforumChannel?: ApiChat; lastMessageStory?: ApiTypeStory; listedTopicIds?: number[]; - topics?: Record; isMuted?: boolean; user?: ApiUser; userStatus?: ApiUserStatus; @@ -142,7 +140,6 @@ const Chat: FC = ({ animationType, isPinned, listedTopicIds, - topics, observeIntersection, chat, monoforumChannel, @@ -170,16 +167,16 @@ const Chat: FC = ({ previewMessageId, className, isSynced, - onDragEnter, - onDragLeave, isAccountFrozen, chatFolderIds, orderedFolderIds, chatFoldersById, areTagsEnabled, withTags, - onReorderAnimationEnd, isFoldersSidebarShown, + onDragEnter, + onDragLeave, + onReorderAnimationEnd, }) => { const { openChat, @@ -245,7 +242,7 @@ const Chat: FC = ({ isSavedDialog, isPreview, onReorderAnimationEnd, - topics, + topicIds: listedTopicIds, hasTags: shouldRenderTags, }); @@ -366,7 +363,7 @@ const Chat: FC = ({ isSavedDialog, currentUserId, isPreview, - topics, + topicIds: listedTopicIds, }); const isIntersecting = useIsIntersecting(ref, chat ? observeIntersection : undefined); @@ -445,7 +442,6 @@ const Chat: FC = ({ isMuted={isMuted} shouldShowOnlyMostImportant forceHidden={getIsForumPanelClosed} - topics={topics} isSelected={isSelected} isOnAvatar /> @@ -485,7 +481,6 @@ const Chat: FC = ({ isMuted={isMuted} isSavedDialog={isSavedDialog} hasMiniApp={user?.hasMainMiniApp} - topics={topics} isSelected={isSelected} transitionClassName="chat-badge-transition" /> @@ -557,7 +552,7 @@ export default memo(withGlobal( const { chatId: currentChatId, threadId: currentThreadId, - type: messageListType, + type: currentMessageListType, } = selectCurrentMessageList(global) || {}; const isSelected = !isPreview && chatId === currentChatId && (isSavedDialog ? chatId === currentThreadId : currentThreadId === MAIN_THREAD_ID); @@ -567,7 +562,7 @@ export default memo(withGlobal( const userStatus = selectUserStatus(global, chatId); const lastMessageTopic = lastMessage && selectTopicFromMessage(global, lastMessage); - const typingStatus = selectThreadParam(global, chatId, MAIN_THREAD_ID, 'typingStatus'); + const typingStatus = selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'typingStatus'); const topicsInfo = selectTopicsInfo(global, chatId); @@ -585,9 +580,11 @@ export default memo(withGlobal( isSelected, isSelectedForum, isForumPanelOpen: selectIsForumPanelOpen(global), - canScrollDown: isSelected && messageListType === 'thread', + canScrollDown: isSelected && currentMessageListType === 'thread', canChangeFolder: (global.chatFolders.orderedIds?.length || 0) > 1, - lastMessageOutgoingStatus: isOutgoing && lastMessage ? selectOutgoingStatus(global, lastMessage) : undefined, + lastMessageOutgoingStatus: isOutgoing && lastMessage && !isSavedDialog + ? selectOutgoingStatus(global, chatId, MAIN_THREAD_ID, lastMessage.id, 'thread') + : undefined, user, userStatus, lastMessageTopic, @@ -597,7 +594,6 @@ export default memo(withGlobal( lastMessageId, currentUserId: global.currentUserId!, listedTopicIds: topicsInfo?.listedTopicIds, - topics: topicsInfo?.topicsById, isSynced: global.isSynced, lastMessageStory, isAccountFrozen, diff --git a/src/components/left/main/ChatBadge.tsx b/src/components/left/main/ChatBadge.tsx index 3efae178a..15b488c79 100644 --- a/src/components/left/main/ChatBadge.tsx +++ b/src/components/left/main/ChatBadge.tsx @@ -1,15 +1,20 @@ -import { memo, useMemo } from '../../../lib/teact/teact'; +import { memo, useCallback, useMemo } from '../../../lib/teact/teact'; import { getActions } from '../../../global'; -import type { ApiChat, ApiTopic } from '../../../api/types'; +import type { GlobalState } from '../../../global/types'; import type { Signal } from '../../../util/signals'; +import { type ApiChat, type ApiTopic, MAIN_THREAD_ID } from '../../../api/types'; +import { selectTopicsInfo } from '../../../global/selectors'; +import { selectThreadReadState } from '../../../global/selectors/threads'; import buildClassName from '../../../util/buildClassName'; +import { buildCollectionByCallback } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; import { isSignal } from '../../../util/signals'; import { formatIntegerCompact } from '../../../util/textFormat'; import { extractCurrentThemeParams } from '../../../util/themeStyle'; +import useSelector, { useShallowSelector } from '../../../hooks/data/useSelector'; import useDerivedState from '../../../hooks/useDerivedState'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; @@ -31,7 +36,6 @@ type OwnProps = { shouldShowOnlyMostImportant?: boolean; hasMiniApp?: boolean; forceHidden?: boolean | Signal; - topics?: Record; isSelected?: boolean; isOnAvatar?: boolean; transitionClassName?: string; @@ -40,7 +44,6 @@ type OwnProps = { const ChatBadge = ({ topic, - topics, chat, isPinned, isMuted, @@ -58,23 +61,47 @@ const ChatBadge = ({ const lang = useLang(); + const readStateSelector = useCallback((global: GlobalState) => { + return selectThreadReadState(global, chat.id, topic?.id || MAIN_THREAD_ID); + }, [chat.id, topic?.id]); + + const readState = useSelector(readStateSelector); + const { - unreadMentionsCount = 0, unreadReactionsCount = 0, - } = !chat.isForum ? chat : {}; // TODO[forums] Unread mentions and reactions temporarily disabled for forums + unreadMentionsCount: stateUnreadMentionsCount = 0, + unreadReactionsCount: stateUnreadReactionsCount = 0, + unreadCount: stateUnreadCount = 0, + hasUnreadMark, + } = readState || {}; + + const topicsInfoSelector = useCallback((global: GlobalState) => { + return selectTopicsInfo(global, chat.id); + }, [chat.id]); + const topicsInfo = useShallowSelector(topicsInfoSelector); + const { listedTopicIds, topicsById } = topicsInfo || {}; + + const topicsReadStateSelector = useCallback((global: GlobalState) => { + return buildCollectionByCallback(listedTopicIds || [], (tId) => ( + [tId, selectThreadReadState(global, chat.id, tId)] + )); + }, [chat.id, listedTopicIds]); + const topicsReadStates = useShallowSelector(topicsReadStateSelector); const isTopicUnopened = !isPinned && topic && !wasTopicOpened; const isForum = chat.isForum && !topic; - const topicsWithUnread = useMemo(() => ( - isForum && topics ? Object.values(topics).filter(({ unreadCount }) => unreadCount) : undefined - ), [topics, isForum]); + const topicsWithUnreadIds = useMemo(() => ( + isForum && listedTopicIds ? listedTopicIds.filter((tId) => topicsReadStates[tId]?.unreadCount) : undefined + ), [listedTopicIds, isForum, topicsReadStates]); + const topicsWithUnreadMentionsIds = useMemo(() => ( + isForum && listedTopicIds ? listedTopicIds.filter((tId) => topicsReadStates[tId]?.unreadMentionsCount) : undefined + ), [listedTopicIds, isForum, topicsReadStates]); + const topicsWithUnreadReactionsIds = useMemo(() => ( + isForum && listedTopicIds ? listedTopicIds.filter((tId) => topicsReadStates[tId]?.unreadReactionsCount) : undefined + ), [listedTopicIds, isForum, topicsReadStates]); - const unreadCount = useMemo(() => { - if (!isForum) { - return (topic || chat).unreadCount; - } - - return topicsWithUnread?.length; - }, [chat, topic, topicsWithUnread, isForum]); + const unreadCount = isForum ? topicsWithUnreadIds?.length : stateUnreadCount; + const unreadMentionsCount = isForum ? topicsWithUnreadMentionsIds?.length : stateUnreadMentionsCount; + const unreadReactionsCount = isForum ? topicsWithUnreadReactionsIds?.length : stateUnreadReactionsCount; const shouldBeUnMuted = useMemo(() => { if (!isForum) { @@ -82,17 +109,17 @@ const ChatBadge = ({ } if (isMuted) { - return topicsWithUnread?.some((acc) => acc.notifySettings.mutedUntil === 0); + return topicsWithUnreadIds?.some((tId) => topicsById?.[tId]?.notifySettings.mutedUntil === 0); } - const isEveryUnreadMuted = topicsWithUnread?.every((acc) => ( - acc.notifySettings.mutedUntil && acc.notifySettings.mutedUntil > getServerTime() - )); + const isEveryUnreadMuted = topicsWithUnreadIds?.every((tId) => { + const mutedUntil = topicsById?.[tId]?.notifySettings.mutedUntil; + return mutedUntil && mutedUntil > getServerTime(); + }); return !isEveryUnreadMuted; - }, [isForum, isMuted, topicsWithUnread, topic?.notifySettings.mutedUntil]); + }, [isForum, isMuted, topicsWithUnreadIds, topicsById, topic?.notifySettings.mutedUntil]); - const hasUnreadMark = topic ? false : chat.hasUnreadMark; const isUnread = Boolean((unreadCount || hasUnreadMark) && !isSavedDialog); const resolvedForceHidden = useDerivedState( diff --git a/src/components/left/main/forum/ForumPanel.tsx b/src/components/left/main/forum/ForumPanel.tsx index 9609a66cb..ba2f0f4a8 100644 --- a/src/components/left/main/forum/ForumPanel.tsx +++ b/src/components/left/main/forum/ForumPanel.tsx @@ -1,10 +1,11 @@ import { beginHeavyAnimation, - memo, useEffect, useMemo, useRef, useState, + memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../../global'; import type { ApiChat } from '../../../../api/types'; +import type { GlobalState } from '../../../../global/types'; import type { TopicsInfo } from '../../../../types'; import { MAIN_THREAD_ID } from '../../../../api/types'; @@ -21,13 +22,16 @@ import { selectTabState, selectTopicsInfo, } from '../../../../global/selectors'; +import { selectThread } from '../../../../global/selectors/threads'; import { IS_TOUCH_ENV } from '../../../../util/browser/windowEnvironment'; import buildClassName from '../../../../util/buildClassName'; import captureEscKeyListener from '../../../../util/captureEscKeyListener'; import { captureEvents, SwipeDirection } from '../../../../util/captureEvents'; import { waitForTransitionEnd } from '../../../../util/cssAnimationEndListeners'; import { isUserId } from '../../../../util/entities/ids'; +import { mapTruthyValues, mapValues } from '../../../../util/iteratees'; +import useSelector from '../../../../hooks/data/useSelector'; import useAppLayout from '../../../../hooks/useAppLayout'; import useHistoryBack from '../../../../hooks/useHistoryBack'; import useInfiniteScroll from '../../../../hooks/useInfiniteScroll'; @@ -122,10 +126,18 @@ const ForumPanel = ({ setIsScrolled(!isIntersecting); }); + const topicsThreadSelector = useCallback((global: GlobalState) => { + if (!chat?.id) return undefined; + return mapTruthyValues(topicsInfo?.topicsById || {}, (t) => selectThread(global, chat.id, t.id)); + }, [chat?.id, topicsInfo?.topicsById]); + const topicsThreads = useSelector(topicsThreadSelector); + const orderedIds = useMemo(() => { - const ids = topicsInfo + const topicsThreadInfos = topicsThreads && mapValues(topicsThreads, (t) => t.threadInfo); + const ids = topicsInfo && topicsThreads ? getOrderedTopics( Object.values(topicsInfo.topicsById), + topicsThreadInfos, topicsInfo.orderedPinnedTopicIds, ).map(({ id }) => id) : []; @@ -133,7 +145,7 @@ const ForumPanel = ({ if (!chat?.isBotForum) return ids; return [MAIN_THREAD_ID, ...ids]; - }, [chat?.isBotForum, topicsInfo]); + }, [chat?.isBotForum, topicsInfo, topicsThreads]); const { orderDiffById, shiftDiff, getAnimationType, onReorderAnimationEnd } = useOrderDiff(orderedIds, 0, chat?.id); diff --git a/src/components/left/main/forum/Topic.tsx b/src/components/left/main/forum/Topic.tsx index 40956a8b5..6f48e3b80 100644 --- a/src/components/left/main/forum/Topic.tsx +++ b/src/components/left/main/forum/Topic.tsx @@ -17,16 +17,19 @@ import { selectChat, selectChatMessage, selectCurrentMessageList, - selectDraft, selectNotifyDefaults, selectNotifyException, selectOutgoingStatus, selectPeerStory, selectSender, - selectThreadInfo, - selectThreadParam, - selectTopics, + selectTopicsInfo, } from '../../../../global/selectors'; +import { + selectDraft, + selectThreadInfo, + selectThreadLocalStateParam, + selectThreadReadState, +} from '../../../../global/selectors/threads'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment'; import buildClassName from '../../../../util/buildClassName'; import { createLocationHash } from '../../../../util/routing'; @@ -73,7 +76,8 @@ type StateProps = { canScrollDown?: boolean; wasTopicOpened?: boolean; withInterfaceAnimations?: boolean; - topics?: Record; + topicIds?: number[]; + unreadCount?: number; }; const Topic = ({ @@ -97,7 +101,8 @@ const Topic = ({ typingStatus, draft, wasTopicOpened, - topics, + topicIds, + unreadCount, onReorderAnimationEnd, }: OwnProps & StateProps) => { const { @@ -149,7 +154,7 @@ const Topic = ({ observeIntersection, isTopic: true, typingStatus, - topics, + topicIds, statefulMediaContent: groupStatefulContent({ story: lastMessageStory }), animationType, @@ -180,6 +185,7 @@ const Topic = ({ isChatMuted, wasOpened: wasTopicOpened, canDelete, + unreadCount, handleDelete: handleOpenDeleteModal, handleMute, handleUnmute, @@ -226,7 +232,6 @@ const Topic = ({ isMuted={isMuted} topic={topic} wasTopicOpened={wasTopicOpened} - topics={topics} isSelected={isSelected} />
@@ -259,14 +264,17 @@ export default memo(withGlobal( (global, { chatId, topic, isSelected }) => { const chat = selectChat(global, chatId); - const lastMessage = selectChatMessage(global, chatId, topic.lastMessageId); + const threadInfo = selectThreadInfo(global, chatId, topic.id); + const lastMessage = threadInfo?.lastMessageId + ? selectChatMessage(global, chatId, threadInfo.lastMessageId) : undefined; const { isOutgoing } = lastMessage || {}; const lastMessageSender = lastMessage && selectSender(global, lastMessage); - const typingStatus = selectThreadParam(global, chatId, topic.id, 'typingStatus'); + const typingStatus = selectThreadLocalStateParam(global, chatId, topic.id, 'typingStatus'); const draft = selectDraft(global, chatId, topic.id); - const threadInfo = selectThreadInfo(global, chatId, topic.id); - const wasTopicOpened = chat?.isBotForum || Boolean(threadInfo?.lastReadInboxMessageId); - const topics = selectTopics(global, chatId); + + const readState = selectThreadReadState(global, chatId, topic.id); + const wasTopicOpened = chat?.isBotForum || Boolean(readState?.lastReadInboxMessageId); + const topicIds = selectTopicsInfo(global, chatId)?.listedTopicIds; const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; @@ -287,12 +295,13 @@ export default memo(withGlobal( withInterfaceAnimations: selectCanAnimateInterface(global), draft, ...(isOutgoing && lastMessage && { - lastMessageOutgoingStatus: selectOutgoingStatus(global, lastMessage), + lastMessageOutgoingStatus: selectOutgoingStatus(global, chatId, topic.id, lastMessage.id, 'thread'), }), canScrollDown: isSelected && chat?.id === currentChatId && currentThreadId === topic.id, wasTopicOpened, - topics, + topicIds, lastMessageStory, + unreadCount: readState?.unreadCount, }; }, )(Topic)); diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index c852e639f..06cf59d3c 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -36,7 +36,7 @@ import TypingStatus from '../../../common/TypingStatus'; export default function useChatListEntry({ chat, - topics, + topicIds, lastMessage, statefulMediaContent, chatId, @@ -56,7 +56,7 @@ export default function useChatListEntry({ onReorderAnimationEnd, }: { chat?: ApiChat; - topics?: Record; + topicIds?: number[]; lastMessage?: ApiMessage; statefulMediaContent: StatefulMediaContent | undefined; chatId: string; @@ -157,7 +157,7 @@ export default function useChatListEntry({ chat={chat} renderLastMessage={renderLastMessageOrTyping} observeIntersection={observeIntersection} - topics={topics} + topicIds={topicIds} hasTags={hasTags} /> ); diff --git a/src/components/left/main/hooks/useTopicContextActions.ts b/src/components/left/main/hooks/useTopicContextActions.ts index 012631ee0..8bb233881 100644 --- a/src/components/left/main/hooks/useTopicContextActions.ts +++ b/src/components/left/main/hooks/useTopicContextActions.ts @@ -1,4 +1,4 @@ -import { useMemo } from '../../../../lib/teact/teact'; +import { useMemo } from '@teact'; import { getActions } from '../../../../global'; import type { ApiChat, ApiTopic } from '../../../../api/types'; @@ -14,6 +14,7 @@ import useOldLang from '../../../../hooks/useOldLang'; export default function useTopicContextActions({ topic, + unreadCount, chat, isChatMuted, wasOpened, @@ -24,6 +25,7 @@ export default function useTopicContextActions({ }: { topic: ApiTopic; chat: ApiChat; + unreadCount?: number; isChatMuted?: boolean; wasOpened?: boolean; canDelete?: boolean; @@ -34,7 +36,7 @@ export default function useTopicContextActions({ const lang = useLang(); const oldLang = useOldLang(); - return useMemo(() => { + const preparedActions = useMemo(() => { const { isPinned, notifySettings, isClosed, id: topicId, } = topic; @@ -68,7 +70,7 @@ export default function useTopicContextActions({ }, }; - const actionUnreadMark = topic.unreadCount || !wasOpened + const actionUnreadMark = unreadCount || !wasOpened ? { title: oldLang('MarkAsRead'), icon: 'readchats', @@ -130,5 +132,10 @@ export default function useTopicContextActions({ actionCloseTopic, actionDelete, ]) as MenuItemContextAction[]; - }, [topic, chat, isChatMuted, wasOpened, lang, oldLang, canDelete, handleDelete, handleMute, handleUnmute]); + }, [ + chat, topic, unreadCount, wasOpened, isChatMuted, canDelete, + handleDelete, handleMute, handleUnmute, lang, oldLang, + ]); + + return preparedActions; } diff --git a/src/components/left/search/LeftSearchResultChat.tsx b/src/components/left/search/LeftSearchResultChat.tsx index 219483a44..f7ca92cc6 100644 --- a/src/components/left/search/LeftSearchResultChat.tsx +++ b/src/components/left/search/LeftSearchResultChat.tsx @@ -1,4 +1,3 @@ -import type { FC } from '@teact'; import { memo, useCallback } from '@teact'; import { getActions, withGlobal } from '../../../global'; @@ -12,6 +11,7 @@ import { selectIsChatPinned, selectNotifyDefaults, selectNotifyException, + selectTopicsInfo, selectUser, } from '../../../global/selectors'; import { onDragEnter, onDragLeave } from '../../../util/dragNDropHandlers.ts'; @@ -41,22 +41,24 @@ type OwnProps = { type StateProps = { chat?: ApiChat; user?: ApiUser; + listedTopicIds?: number[]; isPinned?: boolean; isMuted?: boolean; canChangeFolder?: boolean; }; -const LeftSearchResultChat: FC = ({ +const LeftSearchResultChat = ({ chatId, withUsername, chat, user, + listedTopicIds, isPinned, isMuted, canChangeFolder, withOpenAppButton, onClick, -}) => { +}: OwnProps & StateProps) => { const { requestMainWebView, updateChatMutedState, openQuickPreview } = getActions(); const lang = useLang(); @@ -85,6 +87,7 @@ const LeftSearchResultChat: FC = ({ isPinned, isMuted, canChangeFolder, + topicIds: listedTopicIds, handleMute, handleUnmute, handleChatFolderChange, @@ -186,12 +189,15 @@ export default memo(withGlobal( const isPinned = selectIsChatPinned(global, chatId); const isMuted = chat && getIsChatMuted(chat, selectNotifyDefaults(global), selectNotifyException(global, chat.id)); + const listedTopicIds = selectTopicsInfo(global, chatId)?.listedTopicIds; + return { chat, user, isPinned, isMuted, canChangeFolder: Boolean(global.chatFolders.orderedIds?.length), + listedTopicIds, }; }, )(LeftSearchResultChat)); diff --git a/src/components/middle/ContactGreeting.tsx b/src/components/middle/ContactGreeting.tsx index 0f11211c2..afca132b5 100644 --- a/src/components/middle/ContactGreeting.tsx +++ b/src/components/middle/ContactGreeting.tsx @@ -4,19 +4,21 @@ import { } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { - ApiBusinessIntro, ApiSticker, ApiUpdateConnectionStateType, ApiUser, -} from '../../api/types'; import type { MessageList } from '../../types'; +import { + type ApiBusinessIntro, type ApiSticker, type ApiUpdateConnectionStateType, type ApiUser, + MAIN_THREAD_ID, +} from '../../api/types'; import { getUserFullName } from '../../global/helpers'; import { selectChat, - selectChatLastMessage, + selectChatLastMessageId, selectCurrentMessageList, selectUser, selectUserFullInfo, } from '../../global/selectors'; +import { selectThreadReadState } from '../../global/selectors/threads'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; @@ -31,7 +33,7 @@ type OwnProps = { type StateProps = { defaultStickers?: ApiSticker[]; - lastUnreadMessageId?: number; + lastMessageId?: number; connectionState?: ApiUpdateConnectionStateType; currentMessageList?: MessageList; businessIntro?: ApiBusinessIntro; @@ -41,7 +43,7 @@ type StateProps = { const ContactGreeting: FC = ({ defaultStickers, connectionState, - lastUnreadMessageId, + lastMessageId, currentMessageList, businessIntro, user, @@ -52,7 +54,7 @@ const ContactGreeting: FC = ({ markMessageListRead, } = getActions(); - const lang = useOldLang(); + const oldLang = useOldLang(); const containerRef = useRef(); @@ -73,10 +75,10 @@ const ContactGreeting: FC = ({ }, [connectionState, loadGreetingStickers, defaultStickers]); useEffect(() => { - if (connectionState === 'connectionStateReady' && lastUnreadMessageId) { - markMessageListRead({ maxId: lastUnreadMessageId }); + if (connectionState === 'connectionStateReady' && lastMessageId) { + markMessageListRead({ maxId: lastMessageId }); } - }, [connectionState, markMessageListRead, lastUnreadMessageId]); + }, [connectionState, lastMessageId]); const handleStickerSelect = useLastCallback(() => { if (!currentMessageList) { @@ -92,8 +94,8 @@ const ContactGreeting: FC = ({ }); }); - const title = businessIntro?.title || lang('Conversation.EmptyPlaceholder'); - const description = businessIntro?.description || lang('Conversation.GreetingText'); + const title = businessIntro?.title || oldLang('Conversation.EmptyPlaceholder'); + const description = businessIntro?.description || oldLang('Conversation.GreetingText'); return (
@@ -114,7 +116,7 @@ const ContactGreeting: FC = ({
{businessIntro && (
- {lang('Chat.EmptyStateIntroFooter', getUserFullName(user))} + {oldLang('Chat.EmptyStateIntroFooter', getUserFullName(user))}
)}
@@ -131,14 +133,16 @@ export default memo(withGlobal( const user = selectUser(global, userId); const fullInfo = selectUserFullInfo(global, userId); + const { + unreadCount, + } = selectThreadReadState(global, chat.id, MAIN_THREAD_ID) || {}; - const lastMessage = selectChatLastMessage(global, chat.id); + // Pass last message id only if there are unread messages + const lastMessageId = selectChatLastMessageId(global, chat.id); return { defaultStickers: stickers, - lastUnreadMessageId: lastMessage && lastMessage.id !== chat.lastReadInboxMessageId - ? lastMessage.id - : undefined, + lastMessageId: unreadCount ? lastMessageId : undefined, connectionState: global.connectionState, currentMessageList: selectCurrentMessageList(global), businessIntro: fullInfo?.businessIntro, diff --git a/src/components/middle/FloatingActionButtons.tsx b/src/components/middle/FloatingActionButtons.tsx index 2c15a2d2d..8cb8ed6f2 100644 --- a/src/components/middle/FloatingActionButtons.tsx +++ b/src/components/middle/FloatingActionButtons.tsx @@ -1,11 +1,10 @@ -import type { FC } from '../../lib/teact/teact'; import { memo, useEffect, useRef } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { MessageListType, ThreadId } from '../../types'; -import { MAIN_THREAD_ID } from '../../api/types'; +import type { MessageListType, ThreadId, ThreadReadState } from '../../types'; import { selectChat, selectCurrentMessageList, selectCurrentMiddleSearch } from '../../global/selectors'; +import { selectThreadReadState } from '../../global/selectors/threads'; import buildClassName from '../../util/buildClassName'; import useLastCallback from '../../hooks/useLastCallback'; @@ -24,35 +23,33 @@ type StateProps = { chatId?: string; messageListType?: MessageListType; threadId?: ThreadId; - unreadCount?: number; - unreadReactions?: number[]; - unreadMentions?: number[]; - reactionsCount?: number; - mentionsCount?: number; + threadReadState?: ThreadReadState; + shouldShowCount?: boolean; }; -const FloatingActionButtons: FC = ({ +const FloatingActionButtons = ({ withScrollDown, canPost, messageListType, chatId, threadId, - unreadCount, - unreadReactions, - unreadMentions, - reactionsCount, - mentionsCount, withExtraShift, -}) => { + threadReadState, + shouldShowCount, +}: OwnProps & StateProps) => { const { - focusNextReply, focusNextReaction, focusNextMention, fetchUnreadReactions, - readAllMentions, readAllReactions, fetchUnreadMentions, scrollMessageListToBottom, + focusNextReply, focusNextReaction, focusNextMention, loadUnreadReactions, + readAllMentions, readAllReactions, loadUnreadMentions, scrollMessageListToBottom, } = getActions(); const elementRef = useRef(); - const hasUnreadReactions = Boolean(reactionsCount); - const hasUnreadMentions = Boolean(mentionsCount); + const { + unreadReactionsCount, unreadMentionsCount, unreadCount, unreadReactions, unreadMentions, + } = (shouldShowCount && threadReadState) || {}; + + const hasUnreadReactions = Boolean(unreadReactionsCount); + const hasUnreadMentions = Boolean(unreadMentionsCount); const handleReadAllReactions = useLastCallback(() => { if (!chatId) return; @@ -66,27 +63,15 @@ const FloatingActionButtons: FC = ({ useEffect(() => { if (hasUnreadReactions && chatId && !unreadReactions?.length) { - fetchUnreadReactions({ chatId }); + loadUnreadReactions({ chatId, threadId }); } - }, [chatId, fetchUnreadReactions, hasUnreadReactions, unreadReactions?.length]); - - useEffect(() => { - if (hasUnreadReactions && chatId) { - fetchUnreadReactions({ chatId }); - } - }, [chatId, fetchUnreadReactions, hasUnreadReactions]); + }, [chatId, threadId, hasUnreadReactions, unreadReactions?.length]); useEffect(() => { if (hasUnreadMentions && chatId && !unreadMentions?.length) { - fetchUnreadMentions({ chatId }); + loadUnreadMentions({ chatId, threadId }); } - }, [chatId, fetchUnreadMentions, hasUnreadMentions, unreadMentions?.length]); - - useEffect(() => { - if (hasUnreadMentions && chatId) { - fetchUnreadMentions({ chatId }); - } - }, [chatId, fetchUnreadMentions, hasUnreadMentions]); + }, [chatId, threadId, hasUnreadMentions, unreadMentions?.length]); const handleScrollDownClick = useLastCallback(() => { if (!withScrollDown) { @@ -100,10 +85,20 @@ const FloatingActionButtons: FC = ({ } }); + const handleFocusNextReaction = useLastCallback(() => { + if (!chatId) return; + focusNextReaction({ chatId, threadId }); + }); + + const handleFocusNextMention = useLastCallback(() => { + if (!chatId) return; + focusNextMention({ chatId, threadId }); + }); + const fabClassName = buildClassName( styles.root, - (withScrollDown || Boolean(reactionsCount) || Boolean(mentionsCount)) && styles.revealed, - (Boolean(reactionsCount) || Boolean(mentionsCount)) && !withScrollDown && styles.hideScrollDown, + (withScrollDown || hasUnreadReactions || hasUnreadMentions) && styles.revealed, + (hasUnreadReactions || hasUnreadMentions) && !withScrollDown && styles.hideScrollDown, !canPost && styles.noComposer, !withExtraShift && styles.noExtraShift, ); @@ -113,9 +108,9 @@ const FloatingActionButtons: FC = ({ = ({ @@ -154,18 +149,14 @@ export default memo(withGlobal( const chat = selectChat(global, chatId); const hasActiveMiddleSearch = Boolean(selectCurrentMiddleSearch(global)); - const shouldShowCount = chat && threadId === MAIN_THREAD_ID && messageListType === 'thread' - && !hasActiveMiddleSearch; + const shouldShowCount = chat && messageListType === 'thread' && !hasActiveMiddleSearch; return { messageListType, chatId, threadId, - reactionsCount: shouldShowCount ? chat.unreadReactionsCount : undefined, - unreadReactions: shouldShowCount ? chat.unreadReactions : undefined, - unreadMentions: shouldShowCount ? chat.unreadMentions : undefined, - mentionsCount: shouldShowCount ? chat.unreadMentionsCount : undefined, - unreadCount: shouldShowCount ? chat.unreadCount : undefined, + threadReadState: selectThreadReadState(global, chatId, threadId), + shouldShowCount, }; }, )(FloatingActionButtons)); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index b5deaff59..53f3d4c6b 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -40,18 +40,21 @@ import { selectIsCurrentUserPremium, selectIsInSelectMode, selectIsViewportNewest, - selectLastScrollOffset, selectMonoforumChannel, selectPerformanceSettingsValue, - selectScrollOffset, selectTabState, - selectThreadInfo, selectTopic, selectTranslationLanguage, selectUserFullInfo, } from '../../global/selectors'; import { selectIsChatRestricted } from '../../global/selectors/chats'; import { selectActiveRestrictionReasons, selectCurrentMessageList } from '../../global/selectors/messages'; +import { + selectLastScrollOffset, + selectScrollOffset, + selectThreadInfo, + selectThreadReadState, +} from '../../global/selectors/threads'; import animateScroll, { isAnimatingScroll, restartCurrentScrollAnimation } from '../../util/animateScroll'; import { IS_FIREFOX } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; @@ -907,6 +910,7 @@ export default memo(withGlobal( const currentUserId = global.currentUserId!; const chat = selectChat(global, chatId); const userFullInfo = selectUserFullInfo(global, chatId); + const readState = selectThreadReadState(global, chatId, threadId); if (!chat) { return { currentUserId } as Complete; } @@ -932,7 +936,7 @@ export default memo(withGlobal( const withLastMessageWhenPreloading = ( threadId === MAIN_THREAD_ID - && !messageIds && !chat.unreadCount && !focusingId && lastMessage && !lastMessage.groupedId + && !messageIds && readState && !readState.unreadCount && !focusingId && lastMessage && !lastMessage.groupedId ); const chatBot = selectBot(global, chatId); diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 1d7998a04..86afb57e9 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -37,8 +37,6 @@ import { selectChatFullInfo, selectCurrentMessageList, selectCurrentMiddleSearch, - selectDraft, - selectEditingId, selectIsChatBotNotStarted, selectIsCurrentUserFrozen, selectIsInSelectMode, @@ -50,12 +48,12 @@ import { selectTabState, selectTheme, selectThemeValues, - selectThreadInfo, selectTopic, selectTopics, selectUserFullInfo, } from '../../global/selectors'; import { selectSharedSettings } from '../../global/selectors/sharedState'; +import { selectDraft, selectEditingId, selectThreadInfo } from '../../global/selectors/threads'; import { IS_TAURI } from '../../util/browser/globalEnvironment'; import { IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED, diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index 0f2ce1eb6..89a923719 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -31,9 +31,8 @@ import { selectPinnedIds, selectScheduledIds, selectTabState, - selectThreadInfo, - selectThreadParam, } from '../../global/selectors'; +import { selectThreadInfo, selectThreadLocalStateParam } from '../../global/selectors/threads'; import { IS_TAURI } from '../../util/browser/globalEnvironment'; import { IS_MAC_OS } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; @@ -400,7 +399,7 @@ export default memo(withGlobal( messagesCount = threadInfo?.messagesCount || 0; } - const typingStatus = selectThreadParam(global, chatId, threadId, 'typingStatus'); + const typingStatus = selectThreadLocalStateParam(global, chatId, threadId, 'typingStatus'); const emojiStatus = peer?.emojiStatus; const emojiStatusSticker = emojiStatus && selectCustomEmoji(global, emojiStatus.documentId); diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.tsx b/src/components/middle/composer/ComposerEmbeddedMessage.tsx index 17acf633c..7a058fbad 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.tsx +++ b/src/components/middle/composer/ComposerEmbeddedMessage.tsx @@ -13,10 +13,7 @@ import { selectCanAnimateInterface, selectChat, selectChatMessage, - selectDraft, - selectEditingId, selectEditingMessage, - selectEditingScheduledId, selectForwardedSender, selectIsChatWithSelf, selectIsCurrentUserPremium, @@ -25,6 +22,7 @@ import { selectTheme, } from '../../../global/selectors'; import { selectIsMediaNsfw } from '../../../global/selectors/media'; +import { selectDraft, selectEditingId, selectEditingScheduledId } from '../../../global/selectors/threads'; import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { unique } from '../../../util/iteratees'; diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index 1dc3cf3e9..e502e338c 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -16,8 +16,9 @@ import type { Signal } from '../../../util/signals'; import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../config'; import { requestForcedReflow, requestMutation } from '../../../lib/fasterdom/fasterdom'; -import { selectCanPlayAnimatedEmojis, selectDraft, selectIsInSelectMode } from '../../../global/selectors'; +import { selectCanPlayAnimatedEmojis, selectIsInSelectMode } from '../../../global/selectors'; import { selectSharedSettings } from '../../../global/selectors/sharedState'; +import { selectDraft } from '../../../global/selectors/threads'; import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_ANDROID, IS_EMOJI_SUPPORTED, IS_IOS, IS_TOUCH_ENV, diff --git a/src/components/middle/composer/WebPagePreview.tsx b/src/components/middle/composer/WebPagePreview.tsx index 6aa753962..8ea3fe02c 100644 --- a/src/components/middle/composer/WebPagePreview.tsx +++ b/src/components/middle/composer/WebPagePreview.tsx @@ -16,7 +16,8 @@ import { getWebPagePhoto, getWebPageVideo, } from '../../../global/helpers'; -import { selectNoWebPage, selectTabState, selectWebPage } from '../../../global/selectors'; +import { selectTabState, selectWebPage } from '../../../global/selectors'; +import { selectNoWebPage } from '../../../global/selectors/threads'; import buildClassName from '../../../util/buildClassName'; import useThumbnail from '../../../hooks/media/useThumbnail'; diff --git a/src/components/middle/hooks/useMessageObservers.ts b/src/components/middle/hooks/useMessageObservers.ts index ea6330b4c..57dbc0285 100644 --- a/src/components/middle/hooks/useMessageObservers.ts +++ b/src/components/middle/hooks/useMessageObservers.ts @@ -34,6 +34,8 @@ export default function useMessageObservers( } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE_FOR_READING, + // `memoFirstUnreadIdRef` is set after the first render, firing callback before that can skip some entries, like the last message + shouldSkipFirst: true, }, (entries) => { if (type !== 'thread' || isBackgroundModeActive()) { return; @@ -97,7 +99,7 @@ export default function useMessageObservers( } if (reactionIds.length) { - animateUnreadReaction({ messageIds: reactionIds }); + animateUnreadReaction({ chatId, messageIds: reactionIds }); } if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) { diff --git a/src/components/middle/message/ActionMessage.tsx b/src/components/middle/message/ActionMessage.tsx index fb70ab57a..69e79052f 100644 --- a/src/components/middle/message/ActionMessage.tsx +++ b/src/components/middle/message/ActionMessage.tsx @@ -34,6 +34,7 @@ import { selectSender, selectTabState, } from '../../../global/selectors'; +import { selectThreadReadState } from '../../../global/selectors/threads'; import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_ANDROID, IS_FLUID_BACKGROUND_SUPPORTED } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; @@ -295,7 +296,7 @@ const ActionMessage = ({ if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (hasUnreadReaction) { - animateUnreadReaction({ messageIds: [id] }); + animateUnreadReaction({ chatId, messageIds: [id] }); } if (message.hasUnreadMention) { @@ -672,7 +673,8 @@ export default memo(withGlobal( const isCurrentUserPremium = selectIsCurrentUserPremium(global); - const hasUnreadReaction = chat?.unreadReactions?.includes(message.id); + const readState = selectThreadReadState(global, message.chatId, threadId); + const hasUnreadReaction = readState?.unreadReactions?.includes(message.id); const isAccountFrozen = selectIsCurrentUserFrozen(global); return { diff --git a/src/components/middle/message/ActionMessageText.tsx b/src/components/middle/message/ActionMessageText.tsx index f24d801d8..5ed5d9df0 100644 --- a/src/components/middle/message/ActionMessageText.tsx +++ b/src/components/middle/message/ActionMessageText.tsx @@ -23,9 +23,9 @@ import { selectMonoforumChannel, selectPeer, selectSender, - selectThreadIdFromMessage, selectTopic, } from '../../../global/selectors'; +import { selectThreadIdFromMessage } from '../../../global/selectors/threads'; import { ensureProtocol } from '../../../util/browser/url'; import { formatDateTimeToString, formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat'; import { formatCurrency } from '../../../util/formatCurrency'; diff --git a/src/components/middle/message/CommentButton.tsx b/src/components/middle/message/CommentButton.tsx index 40ae233c2..d9365d70f 100644 --- a/src/components/middle/message/CommentButton.tsx +++ b/src/components/middle/message/CommentButton.tsx @@ -1,8 +1,8 @@ -import type { FC } from '../../../lib/teact/teact'; import { memo, useMemo } from '../../../lib/teact/teact'; import { getActions, getGlobal } from '../../../global'; import type { ApiCommentsInfo } from '../../../api/types'; +import type { ThreadReadState } from '../../../types'; import { selectIsCurrentUserFrozen, selectPeer } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; @@ -22,6 +22,7 @@ import './CommentButton.scss'; type OwnProps = { threadInfo?: ApiCommentsInfo; + threadReadState?: ThreadReadState; disabled?: boolean; isLoading?: boolean; isCustomShape?: boolean; @@ -29,12 +30,13 @@ type OwnProps = { const SHOW_LOADER_DELAY = 450; -const CommentButton: FC = ({ +const CommentButton = ({ isCustomShape, threadInfo, + threadReadState, disabled, isLoading, -}) => { +}: OwnProps) => { const { openThread, openFrozenAccountModal } = getActions(); const shouldRenderLoading = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); @@ -42,8 +44,9 @@ const CommentButton: FC = ({ const oldLang = useOldLang(); const lang = useLang(); const { - originMessageId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId, + originMessageId, chatId, messagesCount, lastMessageId, recentReplierIds, originChannelId, } = threadInfo || {}; + const { lastReadInboxMessageId } = threadReadState || {}; const handleClick = useLastCallback(() => { const global = getGlobal(); diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 7f99184d2..0c2d0962f 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -65,15 +65,14 @@ import { selectPollFromMessage, selectRequestedChatTranslationLanguage, selectRequestedMessageTranslationLanguage, - selectSavedDialogIdFromMessage, selectStickerSet, - selectThreadInfo, selectTopic, selectUser, selectUserStatus, selectWebPageFromMessage, } from '../../../global/selectors'; import { selectMessageDownloadableMedia } from '../../../global/selectors/media'; +import { selectSavedDialogIdFromMessage, selectThreadInfo } from '../../../global/selectors/threads'; import buildClassName from '../../../util/buildClassName'; import { copyTextToClipboard } from '../../../util/clipboard'; import { isUserId } from '../../../util/entities/ids'; @@ -837,7 +836,9 @@ export default memo(withGlobal( const isOwn = isOwnMessage(message); const chatBot = chat && selectBot(global, chat.id); const isBot = Boolean(chatBot); - const isMessageUnread = selectIsMessageUnread(global, message); + const isMessageUnread = selectIsMessageUnread( + global, message.chatId, threadId || MAIN_THREAD_ID, message.id, messageListType, + ); const canLoadReadDate = Boolean( isPrivate && isOwn diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index a536e671e..51760aa8d 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -39,6 +39,7 @@ import type { TextSummary, ThemeKey, ThreadId, + ThreadReadState, } from '../../../types'; import type { Signal } from '../../../util/signals'; import type { OnIntersectPinnedMessage } from '../hooks/usePinnedMessage'; @@ -112,7 +113,6 @@ import { selectShouldLoopStickers, selectTabState, selectTheme, - selectThreadInfo, selectTopicFromMessage, selectUploadProgress, selectUser, @@ -124,6 +124,7 @@ import { selectMessageTimestampableDuration, } from '../../../global/selectors/media'; import { selectSharedSettings } from '../../../global/selectors/sharedState'; +import { selectThread, selectThreadReadState } from '../../../global/selectors/threads'; import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_ANDROID, IS_TRANSLATION_SUPPORTED } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; @@ -293,6 +294,7 @@ type StateProps = { shouldLoopStickers?: boolean; autoLoadFileMaxSizeMb: number; repliesThreadInfo?: ApiThreadInfo; + repliesReadState?: ThreadReadState; reactionMessage?: ApiMessage; availableReactions?: ApiAvailableReaction[]; defaultReaction?: ApiReaction; @@ -428,6 +430,7 @@ const Message = ({ shouldLoopStickers, autoLoadFileMaxSizeMb, repliesThreadInfo, + repliesReadState, hasUnreadReaction, memoFirstUnreadIdRef, senderAdminMember, @@ -839,6 +842,7 @@ const Message = ({ const phoneCall = action?.type === 'phoneCall' ? action : undefined; const commentsThreadInfo = repliesThreadInfo?.isCommentsInfo ? repliesThreadInfo : undefined; + const commentsReadState = repliesThreadInfo?.isCommentsInfo ? repliesReadState : undefined; const isLocalWithCommentButton = hasLinkedChat && isChannel && isLocal; const isMediaWithCommentButton = (commentsThreadInfo || isLocalWithCommentButton) @@ -872,7 +876,8 @@ const Message = ({ asForwarded, hasThread: hasThread && !noComments, forceSenderName, - hasCommentCounter: hasThread && repliesThreadInfo.messagesCount > 0, + hasCommentCounter: hasThread && repliesThreadInfo.messagesCount !== undefined + && repliesThreadInfo.messagesCount > 0, hasBottomCommentButton: withCommentButton && !isCustomShape, hasActionButton: canForward || canFocus || (withCommentButton && isCustomShape), hasReactions, @@ -955,7 +960,7 @@ const Message = ({ if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (hasUnreadReaction) { - animateUnreadReaction({ messageIds: [messageId] }); + animateUnreadReaction({ chatId, messageIds: [messageId] }); } let unreadMentionIds: number[] = []; @@ -1889,6 +1894,7 @@ const Message = ({ {withCommentButton && isCustomShape && ( @@ -2086,7 +2093,8 @@ export default memo(withGlobal( const downloadableMedia = selectMessageDownloadableMedia(global, message); const isDownloading = downloadableMedia && getIsDownloading(activeDownloads, downloadableMedia); - const repliesThreadInfo = selectThreadInfo(global, chatId, album?.commentsMessage?.id || id); + const repliesThread = selectThread(global, chatId, album?.commentsMessage?.id || id); + const { threadInfo: repliesThreadInfo, readState: repliesReadState } = repliesThread || {}; const isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum; const documentGroupFirstMessageId = isInDocumentGroup @@ -2096,7 +2104,8 @@ export default memo(withGlobal( isLastInDocumentGroup ? selectChatMessage(global, chatId, documentGroupFirstMessageId!) : undefined ) : message; - const hasUnreadReaction = chat?.unreadReactions?.includes(message.id); + const readState = selectThreadReadState(global, chatId, threadId); + const hasUnreadReaction = readState?.unreadReactions?.includes(message.id); const hasTopicChip = threadId === MAIN_THREAD_ID && chat?.isForum && !chat.isBotForum && isFirstInGroup; const messageTopic = selectTopicFromMessage(global, message); @@ -2187,6 +2196,7 @@ export default memo(withGlobal( autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb, shouldLoopStickers: selectShouldLoopStickers(global), repliesThreadInfo, + repliesReadState, availableReactions: global.reactions.availableReactions, defaultReaction: isMessageLocal(message) || messageListType === 'scheduled' ? undefined : selectDefaultReaction(global, chatId), @@ -2212,7 +2222,9 @@ export default memo(withGlobal( && loadingThread?.loadingChatId === repliesThreadInfo?.originChannelId && loadingThread?.loadingMessageId === repliesThreadInfo?.originMessageId, shouldWarnAboutFiles, - outgoingStatus: isOutgoing ? selectOutgoingStatus(global, message, messageListType === 'scheduled') : undefined, + outgoingStatus: isOutgoing + ? selectOutgoingStatus(global, chatId, threadId, message.id, messageListType) + : undefined, uploadProgress: typeof uploadProgress === 'number' ? uploadProgress : undefined, focusDirection: isFocused ? focusDirection : undefined, noFocusHighlight: isFocused ? noFocusHighlight : undefined, diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index ca0dfb7c3..1ca9c7fd0 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -191,7 +191,7 @@ export default function useInnerHandlers({ }); const handleReadMedia = useLastCallback((): void => { - markMessagesRead({ messageIds: [messageId] }); + markMessagesRead({ chatId, messageIds: [messageId] }); }); const handleCancelUpload = useLastCallback(() => { diff --git a/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx b/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx index 81e6b4987..2aef8a441 100644 --- a/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx +++ b/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx @@ -7,7 +7,8 @@ import type { ThreadId } from '../../../types'; import { MAIN_THREAD_ID } from '../../../api/types'; import { getIsSavedDialog } from '../../../global/helpers'; -import { selectChat, selectThreadParam, selectTopic } from '../../../global/selectors'; +import { selectChat } from '../../../global/selectors'; +import { selectThreadLocalStateParam, selectThreadReadState } from '../../../global/selectors/threads'; import { isUserId } from '../../../util/entities/ids'; import useConnectionStatus from '../../../hooks/useConnectionStatus'; @@ -75,7 +76,7 @@ const QuickPreviewModalHeader: FC = ({ round color="translucent" size="smaller" - ariaLabel={lang('ChatListContextMaskAsRead')} + ariaLabel={lang('ChatListContextMarkAsRead')} onClick={handleMarkAsRead} className={styles.markAsReadButton} iconName="readchats" @@ -134,11 +135,10 @@ const QuickPreviewModalHeader: FC = ({ export default memo(withGlobal( (global, { chatId, threadId }): Complete => { const chat = selectChat(global, chatId); - const typingStatus = selectThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'typingStatus'); + const typingStatus = selectThreadLocalStateParam(global, chatId, threadId || MAIN_THREAD_ID, 'typingStatus'); const isSavedDialog = getIsSavedDialog(chatId, threadId || MAIN_THREAD_ID, global.currentUserId); - const unreadCount = chat?.isForum && threadId - ? selectTopic(global, chatId, threadId)?.unreadCount - : chat?.unreadCount; + const readState = selectThreadReadState(global, chatId, threadId || MAIN_THREAD_ID); + const unreadCount = readState?.unreadCount; return { chat, @@ -148,7 +148,7 @@ export default memo(withGlobal( typingStatus, isSavedDialog, unreadCount, - hasUnreadMark: chat?.hasUnreadMark, + hasUnreadMark: readState?.hasUnreadMark, }; }, )(QuickPreviewModalHeader)); diff --git a/src/components/modals/stars/transaction/StarsTransactionItem.tsx b/src/components/modals/stars/transaction/StarsTransactionItem.tsx index 22daf675e..91aced0c5 100644 --- a/src/components/modals/stars/transaction/StarsTransactionItem.tsx +++ b/src/components/modals/stars/transaction/StarsTransactionItem.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from '../../../../lib/teact/teact'; +import { memo, useCallback, useMemo } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; import type { @@ -42,12 +42,6 @@ type OwnProps = { const GIFT_STICKER_SIZE = 36; -function selectOptionalPeer(peerId?: string) { - return (global: GlobalState) => ( - peerId ? selectPeer(global, peerId) : undefined - ); -} - const StarsTransactionItem = ({ transaction, className }: OwnProps) => { const { openStarsTransactionModal } = getActions(); const { @@ -62,7 +56,11 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => { const oldLang = useOldLang(); const peerId = transactionPeer.type === 'peer' ? transactionPeer.id : undefined; - const peer = useSelector(selectOptionalPeer(peerId)); + + const peerSelector = useCallback((global: GlobalState) => { + return peerId ? selectPeer(global, peerId) : undefined; + }, [peerId]); + const peer = useSelector(peerSelector); const starGift = transaction.starGift; const isUniqueGift = starGift?.type === 'starGiftUnique'; const giftSticker = starGift && getStickerFromGift(starGift); diff --git a/src/components/modals/suggestMessage/SuggestMessageModal.tsx b/src/components/modals/suggestMessage/SuggestMessageModal.tsx index e02e54e7a..6bea9dd1d 100644 --- a/src/components/modals/suggestMessage/SuggestMessageModal.tsx +++ b/src/components/modals/suggestMessage/SuggestMessageModal.tsx @@ -14,7 +14,7 @@ import { TON_CURRENCY_CODE, } from '../../../config'; import { selectIsMonoforumAdmin, selectPeer } from '../../../global/selectors'; -import { selectDraft } from '../../../global/selectors/messages'; +import { selectDraft } from '../../../global/selectors/threads'; import buildClassName from '../../../util/buildClassName'; import { formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat'; import { convertTonFromNanos, convertTonToNanos } from '../../../util/formatCurrency'; diff --git a/src/components/right/GifSearch.tsx b/src/components/right/GifSearch.tsx index f141f6545..c0b69eb84 100644 --- a/src/components/right/GifSearch.tsx +++ b/src/components/right/GifSearch.tsx @@ -13,9 +13,10 @@ import { selectCurrentGifSearch, selectCurrentMessageList, selectIsChatWithBot, - selectIsChatWithSelf, selectThreadInfo, + selectIsChatWithSelf, selectTopic, } from '../../global/selectors'; +import { selectThreadInfo } from '../../global/selectors/threads'; import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index 60ebe5941..78c47bac8 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -56,7 +56,6 @@ import { selectChatMessage, selectCurrentChat, selectCurrentMessageList, - selectDraft, selectIsCurrentUserFrozen, selectIsTrustedBot, selectMessageReplyInfo, @@ -68,6 +67,7 @@ import { selectUserFullInfo, } from '../../selectors'; import { selectSharedSettings } from '../../selectors/sharedState'; +import { selectDraft } from '../../selectors/threads.ts'; import { fetchChatByUsername } from './chats'; import { getPeerStarsForMessage } from './messages'; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index f9f184328..f2db7e6b1 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -40,7 +40,7 @@ import { isUserId } from '../../../util/entities/ids'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getOrderedIds } from '../../../util/folderManager'; import { - buildCollectionByKey, omit, pick, unique, + buildCollectionByKey, omit, unique, } from '../../../util/iteratees'; import { isLocalMessageId } from '../../../util/keys/messageKey'; import * as langProvider from '../../../util/oldLangProvider'; @@ -79,7 +79,6 @@ import { replaceMessages, replaceNotifyExceptions, replaceSimilarChannels, - replaceThreadParam, replaceUserStatuses, toggleSimilarChannels, updateChat, @@ -92,15 +91,22 @@ import { updateManagementProgress, updateMissingInvitedUsers, updatePeerFullInfo, - updateThread, - updateThreadInfo, updateTopic, - updateTopics, + updateTopicsInfo, + updateTopicWithState, updateUser, updateUsers, } from '../../reducers'; import { updateGroupCall } from '../../reducers/calls'; import { updateTabState } from '../../reducers/tabs'; +import { + replaceThreadLocalStateParam, + replaceThreadReadStateParam, + updateMainThreadReadStates, + updateThreadInfo, + updateThreadInfoLastMessageId, + updateThreadReadState, +} from '../../reducers/threads'; import { selectChat, selectChatByUsername, @@ -112,7 +118,6 @@ import { selectChatMessages, selectCurrentChat, selectCurrentMessageList, - selectDraft, selectIsChatPinned, selectIsChatWithSelf, selectIsCurrentUserFrozen, @@ -123,8 +128,6 @@ import { selectStickerSet, selectSupportChat, selectTabState, - selectThread, - selectThreadInfo, selectTopic, selectTopics, selectTopicsInfo, @@ -133,6 +136,13 @@ import { } from '../../selectors'; import { selectGroupCall } from '../../selectors/calls'; import { selectCurrentLimit } from '../../selectors/limits'; +import { + selectDraft, + selectThread, + selectThreadInfo, + selectThreadLocalState, + selectThreadReadState, +} from '../../selectors/threads'; const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100; const INFINITE_LOOP_MARKER = 100; @@ -227,8 +237,9 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => { } const chat = selectChat(global, id); + const chatReadState = selectThreadReadState(global, id, MAIN_THREAD_ID); - if (chat?.hasUnreadMark) { + if (chatReadState?.hasUnreadMark) { actions.markChatRead({ id }); } @@ -308,14 +319,17 @@ addActionHandler('openThread', async (global, actions, payload): Promise = const chat = selectChat(global, loadingChatId); const threadInfo = selectThreadInfo(global, loadingChatId, loadingThreadId); - const thread = selectThread(global, loadingChatId, loadingThreadId); + const threadLocalState = selectThreadLocalState(global, loadingChatId, loadingThreadId); if (!chat) return; abortChatRequestsForCurrentChat(global, loadingChatId, loadingThreadId, tabId); if (chatId && threadInfo?.threadId - && (isComments || (thread?.listedIds?.length && thread.listedIds.includes(Number(threadInfo.threadId))))) { + && ( + isComments + || (threadLocalState?.listedIds?.length && threadLocalState.listedIds.includes(Number(threadInfo.threadId))) + )) { global = updateTabState(global, { loadingThread: undefined, }, tabId); @@ -407,29 +421,23 @@ addActionHandler('openThread', async (global, actions, payload): Promise = global = getGlobal(); global = addMessages(global, result.messages); - if (isComments) { - global = updateThreadInfo(global, loadingChatId, loadingThreadId, { - threadId, - }); + global = updateThreadReadState(global, chatId, result.threadId, result.threadReadState); + global = updateThreadInfoLastMessageId(global, chatId, result.threadId, result.lastMessageId); + if (isComments) { const lastMessageId = threadInfo?.lastMessageId !== undefined ? threadInfo.lastMessageId : threadInfo?.messagesCount === 0 ? result.threadId : undefined; - global = updateThreadInfo(global, chatId, threadId, { + global = updateThreadInfo(global, { isCommentsInfo: false, threadId, chatId, fromChannelId: loadingChatId, fromMessageId: loadingThreadId, lastMessageId, - ...(threadInfo - && pick(threadInfo, ['messagesCount', 'lastReadInboxMessageId', 'recentReplierIds']) - ), }); } - global = updateThread(global, chatId, threadId, { - firstMessageId: result.firstMessageId, - }); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'firstMessageId', result.firstMessageId); setGlobal(global); if (focusMessageId) { @@ -535,16 +543,36 @@ addActionHandler('loadAllChats', async (global, actions, payload): Promise return; } - await loadChats( + const result = await loadChats( listType, true, ); + const isFirstBatch = !isCallbackFired; if (!isCallbackFired) { await whenFirstBatchDone?.(); isCallbackFired = true; } + global = getGlobal(); + if (result?.messages) { + if (isFirstBatch) { + global = replaceMessages(global, result.messages); + } else { + global = addMessages(global, result.messages); + } + } + + if (result?.threadInfos) { + result.threadInfos.forEach((threadInfo) => { + global = updateThreadInfo(global, threadInfo); + }); + } + + if (result?.threadReadStatesById) { + global = updateMainThreadReadStates(global, result.threadReadStatesById); + } + setGlobal(global); global = getGlobal(); } }); @@ -1288,11 +1316,12 @@ addActionHandler('markChatUnread', (global, actions, payload): ActionReturnType actions.openFrozenAccountModal({ tabId: getCurrentTabId() }); return; } + const chat = selectChat(global, id); if (!chat) return; void callApi('toggleDialogUnread', { chat, - hasUnreadMark: !chat.hasUnreadMark, + hasUnreadMark: true, }); }); @@ -1306,12 +1335,14 @@ addActionHandler('markChatMessagesRead', async (global, actions, payload): Promi } const chat = selectChat(global, id); + const chatReadState = selectThreadReadState(global, id, MAIN_THREAD_ID); if (!chat) return; + if (!chat.isForum) { await callApi('markMessageListRead', { chat, threadId: MAIN_THREAD_ID }); actions.readAllMentions({ chatId: id }); actions.readAllReactions({ chatId: id }); - if (chat.hasUnreadMark) { + if (chatReadState?.hasUnreadMark) { actions.markChatRead({ id }); } return; @@ -1322,18 +1353,29 @@ addActionHandler('markChatMessagesRead', async (global, actions, payload): Promi let processedCount = 0; while (hasMoreTopics) { + const lastTopicThreadInfo = lastTopic && selectThreadInfo(global, id, lastTopic.id); const result = await callApi('fetchTopics', { - chat, offsetDate: lastTopic?.date, offsetTopicId: lastTopic?.id, offsetId: lastTopic?.lastMessageId, limit: 100, + chat, + offsetDate: lastTopic?.date, + offsetTopicId: lastTopic?.id, + offsetId: lastTopicThreadInfo?.lastMessageId, + limit: 100, }); if (!result?.topics?.length) return; - result.topics.forEach((topic) => { - if (!topic.unreadCount && !topic.unreadMentionsCount && !topic.unreadReactionsCount) return; - actions.markTopicRead({ chatId: id, topicId: topic.id }); + result.topics.forEach((topicWithState) => { + global = updateTopicWithState(global, id, topicWithState); + + const { readState } = topicWithState; + if (readState && !readState.unreadCount && !readState.unreadMentionsCount && !readState.unreadReactionsCount) { + return; + } + + actions.markTopicRead({ chatId: id, topicId: topicWithState.topic.id }); }); - lastTopic = result.topics[result.topics.length - 1]; + lastTopic = result.topics[result.topics.length - 1].topic; processedCount += result.topics.length; if (result.count <= processedCount) { hasMoreTopics = false; @@ -1348,7 +1390,7 @@ addActionHandler('markChatRead', (global, actions, payload): ActionReturnType => callApi('toggleDialogUnread', { chat, - hasUnreadMark: !chat.hasUnreadMark, + hasUnreadMark: undefined, }); }); @@ -1358,9 +1400,8 @@ addActionHandler('markTopicRead', (global, actions, payload): ActionReturnType = const chat = selectChat(global, chatId); if (!chat) return; - const topic = selectTopic(global, chatId, topicId); - - const lastTopicMessageId = topic?.lastMessageId; + const lastTopicThreadInfo = selectThreadInfo(global, chatId, topicId); + const lastTopicMessageId = lastTopicThreadInfo?.lastMessageId; if (!lastTopicMessageId) return; void callApi('markMessageListRead', { @@ -1372,12 +1413,8 @@ addActionHandler('markTopicRead', (global, actions, payload): ActionReturnType = actions.readAllReactions({ chatId, threadId: topicId }); global = getGlobal(); - global = updateTopic(global, chatId, topicId, { - unreadCount: 0, - }); - global = updateThreadInfo(global, chatId, topicId, { - lastReadInboxMessageId: lastTopicMessageId, - }); + global = replaceThreadReadStateParam(global, chatId, topicId, 'lastReadInboxMessageId', lastTopicMessageId); + global = replaceThreadReadStateParam(global, chatId, topicId, 'unreadCount', 0); setGlobal(global); }); @@ -2396,16 +2433,19 @@ addActionHandler('loadTopics', async (global, actions, payload): Promise = } const offsetTopic = !force ? topicsInfo?.listedTopicIds?.reduce((acc, el) => { - const topic = selectTopic(global, chatId, el); - const accTopic = selectTopic(global, chatId, acc); - if (!topic) return acc; - if (!accTopic || topic.lastMessageId < accTopic.lastMessageId) { + const topicThreadInfo = selectThreadInfo(global, chatId, el); + const accTopicThreadInfo = selectThreadInfo(global, chatId, acc); + if (!topicThreadInfo?.lastMessageId) return acc; + if (!accTopicThreadInfo?.lastMessageId || topicThreadInfo.lastMessageId < accTopicThreadInfo.lastMessageId) { return el; } return acc; }) : undefined; - const { id: offsetTopicId, date: offsetDate, lastMessageId: offsetId } = (offsetTopic + const offsetTopicThreadInfo = offsetTopic ? selectThreadInfo(global, chatId, offsetTopic) : undefined; + const offsetId = offsetTopicThreadInfo?.lastMessageId; + + const { id: offsetTopicId, date: offsetDate } = (offsetTopic && selectTopic(global, chatId, offsetTopic)) || {}; const result = await callApi('fetchTopics', { chat, offsetTopicId, offsetId, offsetDate, limit: offsetTopicId ? TOPICS_SLICE : TOPICS_SLICE_SECOND_LOAD, @@ -2415,13 +2455,15 @@ addActionHandler('loadTopics', async (global, actions, payload): Promise = global = getGlobal(); global = addMessages(global, result.messages); - global = updateTopics(global, chatId, result.count, result.topics); - global = updateListedTopicIds(global, chatId, result.topics.map((topic) => topic.id)); - Object.entries(result.draftsById || {}).forEach(([threadId, draft]) => { - global = replaceThreadParam(global, chatId, Number(threadId), 'draft', draft); + result.topics.forEach((topic) => { + global = updateTopicWithState(global, chatId, topic); }); - Object.entries(result.readInboxMessageIdByTopicId || {}).forEach(([topicId, messageId]) => { - global = updateThreadInfo(global, chatId, Number(topicId), { lastReadInboxMessageId: messageId }); + global = updateTopicsInfo(global, chatId, { + totalCount: result.count, + }); + global = updateListedTopicIds(global, chatId, result.topics.map((topicState) => topicState.topic.id)); + Object.entries(result.draftsById || {}).forEach(([threadId, draft]) => { + global = replaceThreadLocalStateParam(global, chatId, Number(threadId), 'draft', draft); }); setGlobal(global); @@ -2445,8 +2487,7 @@ addActionHandler('loadTopicById', async (global, actions, payload): Promise( @@ -3540,7 +3588,7 @@ async function openChatWithParams( let topic = selectTopics(global, chat.id)?.[messageId]; if (!topic) { const topicResult = await callApi('fetchTopicById', { chat, topicId: messageId }); - topic = topicResult?.topic; + topic = topicResult?.topic.topic; } if (topic) { diff --git a/src/global/actions/api/globalSearch.ts b/src/global/actions/api/globalSearch.ts index 824316e52..4a4f2c20a 100644 --- a/src/global/actions/api/globalSearch.ts +++ b/src/global/actions/api/globalSearch.ts @@ -1,7 +1,8 @@ import { getActions } from '../../../global'; import type { - ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiMessageSearchContext, ApiPeer, ApiSearchPostsFlood, ApiTopic, + ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiMessageSearchContext, ApiPeer, ApiSearchPostsFlood, + ApiTopicWithState, ApiUserStatus, } from '../../../api/types'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; @@ -24,7 +25,7 @@ import { updateGlobalSearch, updateGlobalSearchFetchingStatus, updateGlobalSearchResults, - updateTopics, + updateTopicWithState, } from '../../reducers'; import { selectChat, selectChatByUsername, selectChatMessage, selectCurrentGlobalSearchQuery, selectPeer, selectTabState, @@ -205,7 +206,7 @@ async function searchMessagesGlobal(global: T, params: { let result: { messages: ApiMessage[]; userStatusesById?: Record; - topics?: ApiTopic[]; + topics?: ApiTopicWithState[]; totalTopicsCount?: number; totalCount: number; nextOffsetRate?: number; @@ -239,13 +240,13 @@ async function searchMessagesGlobal(global: T, params: { if (inChatResult) { const { - messages, totalCount, nextOffsetId, + messages, totalCount, nextOffsetId, topics: messagesTopics, } = inChatResult; const { topics: localTopics, count } = topics || {}; result = { - topics: localTopics, + topics: messagesTopics.concat(localTopics || []), totalTopicsCount: count, messages, totalCount, @@ -264,11 +265,16 @@ async function searchMessagesGlobal(global: T, params: { maxDate, minDate, }); + if (isDeepLink(query)) { const link = tryParseDeepLink(query); if (link?.type === 'publicMessageLink') { + global = getGlobal(); messageLink = await getMessageByPublicLink(global, link); - } else if (link?.type === 'privateMessageLink') { + } + + if (link?.type === 'privateMessageLink') { + global = getGlobal(); messageLink = await getMessageByPrivateLink(global, link); } } @@ -321,13 +327,17 @@ async function searchMessagesGlobal(global: T, params: { tabId, ); - if (result.topics) { - global = updateTopics(global, peer!.id, result.totalTopicsCount!, result.topics); + if (peer && result.topics?.length) { + result.topics.forEach((topicState) => { + global = updateTopicWithState(global, peer.id, topicState); + }); } - const sortedTopics = result.topics?.map(({ id }) => id).sort((a, b) => b - a); + const sortedTopicIds = result.topics?.sort((a, b) => ( + (b.lastMessageId || b.topic.id) - (a.lastMessageId || a.topic.id) + )).map(({ topic }) => topic.id); global = updateGlobalSearch(global, { - foundTopicIds: sortedTopics, + foundTopicIds: sortedTopicIds, }, tabId); setGlobal(global); diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 0414910c0..95f1c6826 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -63,6 +63,7 @@ import { callApi, cancelApiProgress } from '../../../api/gramjs'; import { getIsSavedDialog, getUserFullName, + groupMessageIdsByThreadId, isChatChannel, isChatSuperGroup, isDeletedUser, @@ -73,7 +74,7 @@ import { } from '../../helpers'; import { isApiPeerChat, isApiPeerUser } from '../../helpers/peers'; import { - addActionHandler, getActions, getGlobal, setGlobal, + addActionHandler, getActions, getGlobal, getPromiseActions, setGlobal, } from '../../index'; import { addChatMessagesById, @@ -84,7 +85,6 @@ import { removeRequestedMessageTranslation, removeUnreadMentions, replaceSettings, - replaceThreadParam, replaceUserStatuses, safeReplacePinnedIds, safeReplaceViewportIds, @@ -102,13 +102,18 @@ import { updateRequestedMessageTranslation, updateScheduledMessages, updateSponsoredMessage, - updateThreadInfo, - updateThreadUnreadFromForwardedMessage, - updateTopic, + updateTopicWithState, updateUploadByMessageKey, updateUserFullInfo, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; +import { + replaceThreadLocalStateParam, + replaceThreadReadStateParam, + updateThreadInfo, + updateThreadInfoMessagesCount, + updateThreadReadState, +} from '../../reducers/threads'; import { selectCanForwardMessage, selectChat, @@ -119,10 +124,7 @@ import { selectCurrentMessageList, selectCurrentViewedStory, selectCustomEmoji, - selectDraft, - selectEditingId, selectEditingMessage, - selectEditingScheduledId, selectFirstMessageId, selectFirstUnreadId, selectFocusedMessageId, @@ -137,7 +139,6 @@ import { selectLanguageCode, selectListedIds, selectMessageReplyInfo, - selectNoWebPage, selectOutlyingListByMessageId, selectPeer, selectPeerStory, @@ -145,20 +146,26 @@ import { selectPollFromMessage, selectRealLastReadId, selectReplyCanBeSentToChat, - selectSavedDialogIdFromMessage, selectScheduledMessage, selectSendAs, selectTabState, - selectThreadIdFromMessage, - selectThreadInfo, - selectThreadParam, - selectTopic, selectTranslationLanguage, selectUser, selectUserFullInfo, selectUserStatus, selectViewportIds, } from '../../selectors'; +import { + selectDraft, + selectEditingId, + selectEditingScheduledId, + selectNoWebPage, + selectSavedDialogIdFromMessage, + selectThreadIdFromMessage, + selectThreadInfo, + selectThreadLocalStateParam, + selectThreadReadState, +} from '../../selectors/threads'; import { updateWithLocalMedia } from '../apiUpdaters/messages'; import { deleteMessages } from '../apiUpdaters/messages'; @@ -299,7 +306,7 @@ async function loadWithBudget( addActionHandler('loadMessage', async (global, actions, payload): Promise => { const { - chatId, messageId, replyOriginForId, threadUpdate, + chatId, messageId, replyOriginForId, } = payload; const chat = selectChat(global, chatId); @@ -307,20 +314,28 @@ addActionHandler('loadMessage', async (global, actions, payload): Promise 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); + 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); }); addActionHandler('loadMessagesById', async (global, actions, payload): Promise => { @@ -953,14 +968,11 @@ async function saveDraft({ const newDraft: ApiDraft | undefined = draft ? { ...draft, replyInfo, - date: Math.floor(Date.now() / 1000), + date: (noLocalTimeUpdate && draft.date) || getServerTime(), isLocal: true, } : undefined; - global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft); - if (!noLocalTimeUpdate) { - global = updateChat(global, chatId, { draftDate: newDraft?.date }); - } + global = replaceThreadLocalStateParam(global, chatId, threadId, 'draft', newDraft); setGlobal(global); @@ -976,8 +988,7 @@ async function saveDraft({ } global = getGlobal(); - global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft); - global = updateChat(global, chatId, { draftDate: newDraft?.date }); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'draft', newDraft); setGlobal(global); } @@ -985,7 +996,7 @@ async function saveDraft({ addActionHandler('toggleMessageWebPage', (global, actions, payload): ActionReturnType => { const { chatId, threadId, noWebPage } = payload; - return replaceThreadParam(global, chatId, threadId, 'noWebPage', noWebPage); + return replaceThreadLocalStateParam(global, chatId, threadId, 'noWebPage', noWebPage); }); addActionHandler('pinMessage', (global, actions, payload): ActionReturnType => { @@ -1017,7 +1028,7 @@ addActionHandler('unpinAllMessages', async (global, actions, payload): Promise { global = updateChatMessage(global, chatId, id, { isPinned: false }); }); - global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'pinnedIds', []); + global = replaceThreadLocalStateParam(global, chat.id, threadId, 'pinnedIds', []); setGlobal(global); }); @@ -1287,16 +1298,9 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const minId = selectFirstUnreadId(global, chatId, threadId); - const topic = selectTopic(global, chatId, threadId); + const threadReadState = selectThreadReadState(global, chatId, threadId); - if (threadId !== MAIN_THREAD_ID && !chat.isForum) { - global = updateThreadInfo(global, chatId, threadId, { - lastReadInboxMessageId: maxId, - }); - return global; - } - - if (!viewportIds || !minId || (!chat.unreadCount && !topic?.unreadCount)) { + if (!viewportIds || !minId || !threadReadState?.unreadCount) { return global; } @@ -1305,42 +1309,21 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn return global; } - if (chat.isForum && topic) { - global = updateThreadInfo(global, chatId, threadId, { - lastReadInboxMessageId: maxId, - }); - const newTopicUnreadCount = Math.max(0, topic.unreadCount - readCount); - if (newTopicUnreadCount === 0 && !chat.isBotForum && chat.unreadCount) { - global = updateChat(global, chatId, { - unreadCount: Math.max(0, chat.unreadCount - 1), - }); - } + const newUnreadCount = Math.max(0, (threadReadState.unreadCount || 0) - readCount); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadCount', newUnreadCount); + global = replaceThreadReadStateParam(global, chatId, threadId, 'lastReadInboxMessageId', maxId); - return updateTopic(global, chatId, Number(threadId), { - unreadCount: newTopicUnreadCount, - }); - } - - return updateChat(global, chatId, { - lastReadInboxMessageId: maxId, - unreadCount: Math.max(0, (chat.unreadCount || 0) - readCount), - }); + return global; }); addActionHandler('markMessagesRead', (global, actions, payload): ActionReturnType => { - const { messageIds, tabId = getCurrentTabId(), shouldFetchUnreadReactions } = payload; - - const chat = selectCurrentChat(global, tabId); + const { chatId, messageIds } = payload; + const chat = selectChat(global, chatId); if (!chat) { return; } - void callApi('markMessagesRead', { chat, messageIds }) - .then(() => { - if (shouldFetchUnreadReactions) { - actions.fetchUnreadReactions({ chatId: chat.id }); - } - }); + void callApi('markMessagesRead', { chat, messageIds }); }); addActionHandler('loadWebPagePreview', async (global, actions, payload): Promise => { @@ -1530,24 +1513,17 @@ addActionHandler('loadScheduledHistory', async (global, actions, payload): Promi global = getGlobal(); global = updateScheduledMessages(global, chat.id, byId); - global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'scheduledIds', ids); + + const idsByThreadId = groupMessageIdsByThreadId(global, chat.id, ids, true); if (!ids.length) { global = updatePeerFullInfo(global, chat.id, { hasScheduledMessages: false }); } - if (chat?.isForum) { - const scheduledPerThread: Record = {}; - messages.forEach((message) => { - const threadId = selectThreadIdFromMessage(global, message); - const scheduledInThread = scheduledPerThread[threadId] || []; - scheduledInThread.push(message.id); - scheduledPerThread[threadId] = scheduledInThread; - }); - - Object.entries(scheduledPerThread).forEach(([threadId, scheduledIds]) => { - global = replaceThreadParam(global, chat.id, Number(threadId), 'scheduledIds', scheduledIds); - }); - } + Object.entries(idsByThreadId).forEach(([tId, newThreadScheduledIds]) => { + const threadId = tId as ThreadId; + if (!chat.isForum && threadId !== MAIN_THREAD_ID) return; + global = replaceThreadLocalStateParam(global, chat.id, threadId, 'scheduledIds', newThreadScheduledIds); + }); setGlobal(global); }); @@ -1798,12 +1774,12 @@ async function loadViewportMessages( } const { - messages, count, + messages, count, topics, } = result; global = getGlobal(); - const localTypingDrafts = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); + const localTypingDrafts = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const typingDraftMessages = localTypingDrafts ? Object.values(localTypingDrafts) .map((id) => selectChatMessage(global, chatId, id)) .filter(Boolean) : []; @@ -1846,45 +1822,18 @@ async function loadViewportMessages( } } - if (count) { - global = updateThreadInfo(global, chat.id, threadId, { - messagesCount: count, - }); + if (count !== undefined) { + global = updateThreadInfoMessagesCount(global, chat.id, threadId, count); } + topics.forEach((topicState) => { + global = updateTopicWithState(global, chat.id, topicState); + }); + setGlobal(global); onLoaded?.(); } -async function loadMessage( - global: T, chat: ApiChat, messageId: number, replyOriginForId?: number, -) { - const result = await callApi('fetchMessage', { chat, messageId }); - if (!result) { - return undefined; - } - - if (result === MESSAGE_DELETED) { - if (replyOriginForId) { - global = getGlobal(); - const replyMessage = selectChatMessage(global, chat.id, replyOriginForId); - global = updateChatMessage(global, chat.id, replyOriginForId, { - ...replyMessage, - replyInfo: undefined, - }); - setGlobal(global); - } - - return undefined; - } - - global = getGlobal(); - global = updateChatMessage(global, chat.id, messageId, result.message); - setGlobal(global); - - return result.message; -} - function findClosestIndex(sourceIds: number[], offsetId: number) { if (offsetId < sourceIds[0]) { return 0; @@ -2297,9 +2246,38 @@ addActionHandler('hideSponsored', async (global, actions, payload): Promise => { - const { chatId, offsetId } = payload; - await fetchUnreadMentions(global, chatId, offsetId); +addActionHandler('loadUnreadMentions', async (global, actions, payload): Promise => { + const { chatId, threadId = MAIN_THREAD_ID, offsetId } = payload; + + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('fetchUnreadMentions', { + chat, + threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined, + offsetId, + }); + + if (!result) return; + + const { messages, topics, totalCount } = result; + + const byId = buildCollectionByKey(messages, 'id'); + const ids = Object.keys(byId).map(Number); + + global = getGlobal(); + global = addChatMessagesById(global, chat.id, byId); + topics.forEach((topicState) => { + global = updateTopicWithState(global, chat.id, topicState); + }); + global = addUnreadMentions({ + global, + chatId, + ids, + totalCount, + }); + + setGlobal(global); }); addActionHandler('approveSuggestedPost', async (global, actions, payload): Promise => { @@ -2373,53 +2351,35 @@ addActionHandler('rejectSuggestedPost', async (global, actions, payload): Promis }); }); -async function fetchUnreadMentions(global: T, chatId: string, offsetId?: number) { - const chat = selectChat(global, chatId); - if (!chat) return; - - const result = await callApi('fetchUnreadMentions', { chat, offsetId }); - - if (!result) return; - - const { messages } = result; - - const byId = buildCollectionByKey(messages, 'id'); - const ids = Object.keys(byId).map(Number); - - global = getGlobal(); - global = addChatMessagesById(global, chat.id, byId); - global = addUnreadMentions(global, chatId, chat, ids); - - setGlobal(global); -} - addActionHandler('markMentionsRead', (global, actions, payload): ActionReturnType => { - const { chatId, messageIds, tabId = getCurrentTabId() } = payload; + const { chatId, messageIds } = payload; const chat = selectChat(global, chatId); if (!chat) return; - global = removeUnreadMentions(global, chatId, chat, messageIds, true); + global = removeUnreadMentions({ + global, + chatId, + ids: messageIds, + }); setGlobal(global); - actions.markMessagesRead({ messageIds, tabId }); + actions.markMessagesRead({ chatId, messageIds }); }); addActionHandler('focusNextMention', async (global, actions, payload): Promise => { - const { tabId = getCurrentTabId() } = payload || {}; + const { chatId, threadId = MAIN_THREAD_ID, tabId = getCurrentTabId() } = payload; - let chat = selectCurrentChat(global, tabId); + let readState = selectThreadReadState(global, chatId, threadId); - if (!chat) return; + if (!readState?.unreadMentions) { + await getPromiseActions().loadUnreadMentions({ chatId, threadId }); - 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; + readState = selectThreadReadState(global, chatId, threadId); + if (!readState?.unreadMentions) return; } - actions.focusMessage({ chatId: chat.id, messageId: chat.unreadMentions[0], tabId }); + actions.focusMessage({ chatId, messageId: readState.unreadMentions[0], tabId }); }); addActionHandler('readAllMentions', (global, actions, payload): ActionReturnType => { @@ -2428,17 +2388,13 @@ addActionHandler('readAllMentions', (global, actions, payload): ActionReturnType const chat = selectChat(global, chatId); if (!chat) return undefined; - callApi('readAllMentions', { chat, threadId: threadId === MAIN_THREAD_ID ? undefined : threadId }); + callApi('readAllMentions', { chat, threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined }); - if (threadId === MAIN_THREAD_ID) { - return updateChat(global, chat.id, { - unreadMentionsCount: undefined, - unreadMentions: undefined, - }); - } - - // TODO[Forums]: Support mentions in threads - return undefined; + global = updateThreadReadState(global, chatId, threadId, { + unreadMentionsCount: 0, + unreadMentions: undefined, + }); + return global; }); addActionHandler('openUrl', (global, actions, payload): ActionReturnType => { @@ -2805,7 +2761,7 @@ addActionHandler('loadMessageViews', async (global, actions, payload): Promise => { - const { chatId, offsetId } = payload; +addActionHandler('loadUnreadReactions', async (global, actions, payload): Promise => { + const { chatId, threadId = MAIN_THREAD_ID, offsetId } = payload; const chat = selectChat(global, chatId); if (!chat) return; - const result = await callApi('fetchUnreadReactions', { chat, offsetId, addOffset: offsetId ? -1 : undefined }); + const result = await callApi('fetchUnreadReactions', { + chat, + threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined, + offsetId, + }); - // Server side bug, when server returns unread reactions count > 0 for deleted messages - if (!result || !result.messages.length) { - global = getGlobal(); - global = updateUnreadReactions(global, chatId, { - unreadReactionsCount: 0, - }); + if (!result) return; - setGlobal(global); - return; - } - - const { messages } = result; + const { messages, topics, totalCount } = result; const byId = buildCollectionByKey(messages, 'id'); const ids = Object.keys(byId).map(Number); global = getGlobal(); global = addChatMessagesById(global, chat.id, byId); - global = updateUnreadReactions(global, chatId, { - unreadReactions: unique([...(chat.unreadReactions || []), ...ids]).sort((a, b) => b - a), + topics.forEach((topicState) => { + global = updateTopicWithState(global, chat.id, topicState); + }); + global = addUnreadReactions({ + global, + chatId, + ids, + totalCount, }); setGlobal(global); }); addActionHandler('animateUnreadReaction', (global, actions, payload): ActionReturnType => { - const { messageIds, tabId = getCurrentTabId() } = payload; + const { chatId, messageIds, tabId = getCurrentTabId() } = payload; - const chat = selectCurrentChat(global, tabId); - if (!chat) return undefined; - - if (!chat.unreadReactionsCount) { - return updateUnreadReactions(global, chat.id, { - unreadReactions: [], - }); - } - - const unreadReactionsCount = Math.max(chat.unreadReactionsCount - messageIds.length, 0); - const unreadReactions = (chat.unreadReactions || []).filter((id) => !messageIds.includes(id)); - - global = updateUnreadReactions(global, chat.id, { - unreadReactions, - unreadReactionsCount, - }); + global = removeUnreadReactions({ global, chatId, ids: messageIds }); setGlobal(global); - actions.markMessagesRead({ messageIds, shouldFetchUnreadReactions: true, tabId }); + actions.markMessagesRead({ chatId, messageIds }); if (!selectPerformanceSettingsValue(global, 'reactionEffects')) return undefined; global = getGlobal(); messageIds.forEach((id) => { - const message = selectChatMessage(global, chat.id, id); + const message = selectChatMessage(global, chatId, id); if (!message) return; const { reaction, isOwn, isUnread } = message.reactions?.recentReactions?.[0] ?? {}; if (reaction && isUnread && !isOwn) { const messageKey = getMessageKey(message); - actions.startActiveReaction({ containerId: messageKey, reaction, tabId: getCurrentTabId() }); + actions.startActiveReaction({ containerId: messageKey, reaction, tabId }); } }); return undefined; }); -addActionHandler('focusNextReaction', (global, actions, payload): ActionReturnType => { - const { tabId = getCurrentTabId() } = payload || {}; - const chat = selectCurrentChat(global, tabId); +addActionHandler('focusNextReaction', async (global, actions, payload): Promise => { + const { chatId, threadId = MAIN_THREAD_ID, tabId = getCurrentTabId() } = payload || {}; + let readState = selectThreadReadState(global, chatId, threadId); - if (!chat?.unreadReactions) { - if (chat?.unreadReactionsCount) { - return updateChat(global, chat.id, { - unreadReactionsCount: 0, - }); - } - return undefined; + if (!readState?.unreadReactions?.length) { + await getPromiseActions().loadUnreadReactions({ chatId, threadId }); + + global = getGlobal(); + readState = selectThreadReadState(global, chatId, threadId); + if (!readState?.unreadReactions?.length) return; } actions.focusMessage({ - chatId: chat.id, messageId: chat.unreadReactions[0], tabId, scrollTargetPosition: 'end', + chatId, threadId, messageId: readState.unreadReactions[0], tabId, scrollTargetPosition: 'end', }); - actions.markMessagesRead({ messageIds: [chat.unreadReactions[0]], tabId }); - return undefined; + actions.markMessagesRead({ chatId, messageIds: [readState.unreadReactions[0]] }); }); addActionHandler('readAllReactions', (global, actions, payload): ActionReturnType => { @@ -566,17 +558,13 @@ addActionHandler('readAllReactions', (global, actions, payload): ActionReturnTyp const chat = selectChat(global, chatId); if (!chat) return undefined; - callApi('readAllReactions', { chat, threadId: threadId === MAIN_THREAD_ID ? undefined : threadId }); + callApi('readAllReactions', { chat, threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined }); - if (threadId === MAIN_THREAD_ID) { - return updateUnreadReactions(global, chat.id, { - unreadReactionsCount: undefined, - unreadReactions: undefined, - }); - } - - // TODO[Forums]: Support unread reactions in threads - return undefined; + global = updateThreadReadState(global, chatId, threadId, { + unreadReactionsCount: 0, + unreadReactions: undefined, + }); + return global; }); addActionHandler('loadTopReactions', async (global): Promise => { diff --git a/src/global/actions/api/sync.ts b/src/global/actions/api/sync.ts index a5caa2a5b..d40aa0245 100644 --- a/src/global/actions/api/sync.ts +++ b/src/global/actions/api/sync.ts @@ -1,7 +1,6 @@ import { addCallback } from '../../../lib/teact/teactn'; -import type { ApiThreadInfo } from '../../../api/types/messages'; -import type { Thread, ThreadId } from '../../../types'; +import type { ThreadId, ThreadLocalState } from '../../../types'; import type { RequiredGlobalActions } from '../../index'; import type { ActionReturnType, GlobalState } from '../../types'; import { MAIN_THREAD_ID } from '../../../api/types'; @@ -18,27 +17,28 @@ import { } from '../../index'; import { addChatMessagesById, - addMessages, safeReplaceViewportIds, updateChats, updateListedIds, - updateThread, - updateThreadInfo, updateUsers, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; +import { updateThreadInfo, updateThreadLocalState } from '../../reducers/threads'; import { selectChat, selectChatMessage, selectChatMessages, selectCurrentMessageList, + selectTabState, + selectTopics, +} from '../../selectors'; +import { selectDraft, selectEditingDraft, selectEditingId, - selectTabState, selectThreadInfo, - selectTopics, -} from '../../selectors'; + selectThreadReadState, +} from '../../selectors/threads'; const RELEASE_STATUS_TIMEOUT = 15000; // 15 sec; @@ -106,48 +106,26 @@ async function loadAndReplaceMessages(global: T, actions: // Memoize drafts const draftChatIds = Object.keys(global.messages.byChatId); - const draftsByChatId = draftChatIds.reduce>>>((acc, chatId) => { - acc[chatId] = Object - .keys(global.messages.byChatId[chatId].threadsById) - .reduce>>((acc2, threadId) => { - acc2[Number(threadId)] = omitUndefined({ - draft: selectDraft(global, chatId, Number(threadId)), - editingId: selectEditingId(global, chatId, Number(threadId)), - editingDraft: selectEditingDraft(global, chatId, Number(threadId)), - }); + const draftsByChatId = draftChatIds + .reduce>>>((acc, chatId) => { + acc[chatId] = Object + .keys(global.messages.byChatId[chatId].threadsById) + .reduce>>((acc2, threadId) => { + acc2[Number(threadId)] = omitUndefined({ + draft: selectDraft(global, chatId, Number(threadId)), + editingId: selectEditingId(global, chatId, Number(threadId)), + editingDraft: selectEditingDraft(global, chatId, Number(threadId)), + }); - return acc2; - }, {}); - return acc; - }, {}); - - // Memoize last messages - const lastMessages = Object.entries(global.chats.lastMessageIds.all || {}).map(([chatId, messageId]) => ( - selectChatMessage(global, chatId, Number(messageId)) - )).filter(Boolean); - const savedLastMessages = Object.values(global.chats.lastMessageIds.saved || {}).map((messageId) => ( - selectChatMessage(global, global.currentUserId!, Number(messageId)) - )).filter(Boolean); - - // Memoize thread infos for last messages - const lastMessagesThreadInfos: { chatId: string; messageId: number; threadInfo: ApiThreadInfo }[] = []; - lastMessages.forEach((message) => { - const threadInfo = selectThreadInfo(global, message.chatId, message.id); - if (threadInfo) { - lastMessagesThreadInfos.push({ - chatId: message.chatId, - messageId: message.id, - threadInfo, - }); - } - }); + return acc2; + }, {}); + return acc; + }, {}); for (const { id: tabId } of Object.values(global.byTabId)) { global = getGlobal(); const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {}; const activeThreadId = currentThreadId || MAIN_THREAD_ID; - const threadInfo = currentChatId && currentThreadId - ? selectThreadInfo(global, currentChatId, currentThreadId) : undefined; const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined; if (currentChatId && currentChat) { const [result, resultDiscussion] = await Promise.all([ @@ -173,12 +151,13 @@ async function loadAndReplaceMessages(global: T, actions: : []; const topics = selectTopics(global, currentChatId); const topicLastMessages = topics ? Object.values(topics) - .map(({ lastMessageId }) => currentChatMessages[lastMessageId]) - .filter(Boolean) - : []; + .map(({ id }) => { + const topicThreadInfo = selectThreadInfo(global, currentChatId, id); + return topicThreadInfo?.lastMessageId ? currentChatMessages[topicThreadInfo.lastMessageId] : undefined; + }).filter(Boolean) : []; const resultMessageIds = result.messages.map(({ id }) => id); - const messagesThreadInfos = pick(global.messages.byChatId[currentChatId].threadsById, resultMessageIds); + const messagesThreads = pick(global.messages.byChatId[currentChatId].threadsById, resultMessageIds); const isDiscussionStartLoaded = !result.messages.length || result.messages.some(({ id }) => id === resultDiscussion?.firstMessageId); @@ -189,13 +168,7 @@ async function loadAndReplaceMessages(global: T, actions: const listedIds = unique(allMessages.map(({ id }) => id)); if (!wasReset) { - global = { - ...global, - messages: { - ...global.messages, - byChatId: {}, - }, - }; + global = resetMessages(global); Object.values(global.byTabId).forEach(({ id: otherTabId }) => { global = updateTabState(global, { @@ -208,17 +181,11 @@ async function loadAndReplaceMessages(global: T, actions: global = addChatMessagesById(global, currentChatId, byId); global = updateListedIds(global, currentChatId, activeThreadId, listedIds); - Object.entries(messagesThreadInfos).forEach(([id, thread]) => { + Object.entries(messagesThreads).forEach(([id, thread]) => { if (!thread?.threadInfo) return; - global = updateThreadInfo(global, currentChatId, id, thread.threadInfo); + global = updateThreadInfo(global, thread.threadInfo); }); - if (threadInfo && !threadInfo.isCommentsInfo && activeThreadId !== MAIN_THREAD_ID) { - global = updateThreadInfo(global, currentChatId, activeThreadId, { - ...pick(threadInfo, ['fromChannelId', 'fromMessageId']), - }); - } - Object.values(global.byTabId).forEach(({ id: otherTabId }) => { const { chatId: otherChatId, threadId: otherThreadId } = selectCurrentMessageList(global, otherTabId) || {}; if (otherChatId === currentChatId && otherThreadId === activeThreadId) { @@ -247,13 +214,7 @@ async function loadAndReplaceMessages(global: T, actions: global = getGlobal(); if (!areMessagesLoaded) { - global = { - ...global, - messages: { - ...global.messages, - byChatId: {}, - }, - }; + global = resetMessages(global); Object.values(global.byTabId).forEach(({ id: otherTabId }) => { global = updateTabState(global, { @@ -263,26 +224,13 @@ async function loadAndReplaceMessages(global: T, actions: } // Restore drafts - Object.keys(draftsByChatId).forEach((chatId) => { const threads = draftsByChatId[chatId]; Object.keys(threads).forEach((threadId) => { - global = updateThread(global, chatId, Number(threadId), draftsByChatId[chatId][Number(threadId)]); + global = updateThreadLocalState(global, chatId, Number(threadId), draftsByChatId[chatId][Number(threadId)]); }); }); - // Restore thread infos - lastMessagesThreadInfos.forEach(({ chatId, messageId, threadInfo }) => { - const memoThreadInfo = selectThreadInfo(global, chatId, messageId); - if (!memoThreadInfo) { - global = updateThreadInfo(global, chatId, String(messageId), threadInfo); - } - }); - - // Restore last messages - global = addMessages(global, lastMessages); - global = addMessages(global, savedLastMessages); - setGlobal(global); Object.values(global.byTabId).forEach(({ id: tabId }) => { @@ -293,17 +241,28 @@ async function loadAndReplaceMessages(global: T, actions: }); } +function resetMessages(global: T) { + return { + ...global, + messages: { + ...global.messages, + byChatId: {}, + }, + }; +} + function loadTopMessages(global: T, chatId: string, threadId: ThreadId) { const currentUserId = global.currentUserId!; const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const realChatId = isSavedDialog ? String(threadId) : chatId; const chat = selectChat(global, realChatId)!; + const readState = selectThreadReadState(global, chatId, threadId); return callApi('fetchMessages', { chat, threadId, - offsetId: !isSavedDialog ? chat.lastReadInboxMessageId : undefined, + offsetId: !isSavedDialog ? readState?.lastReadInboxMessageId : undefined, addOffset: -(Math.round(MESSAGE_LIST_SLICE / 2) + 1), limit: MESSAGE_LIST_SLICE, isSavedDialog, diff --git a/src/global/actions/apiUpdaters/chats.ts b/src/global/actions/apiUpdaters/chats.ts index 0e351a264..828cae704 100644 --- a/src/global/actions/apiUpdaters/chats.ts +++ b/src/global/actions/apiUpdaters/chats.ts @@ -1,4 +1,4 @@ -import type { ApiChat, ApiMessage, ApiUpdateChat } from '../../../api/types'; +import type { ApiChat, ApiUpdateChat } from '../../../api/types'; import type { ActionReturnType } from '../../types'; import { MAIN_THREAD_ID } from '../../../api/types'; @@ -20,16 +20,15 @@ import { replaceChatMessages, replacePeerPhotos, replacePinnedTopicIds, - replaceThreadParam, updateChat, updateChatFullInfo, updateChatListType, updatePeerStoriesHidden, - updateThreadInfo, updateTopic, } from '../../reducers'; -import { updateUnreadReactions } from '../../reducers/reactions'; +import { removeUnreadReactions } from '../../reducers/reactions'; import { updateTabState } from '../../reducers/tabs'; +import { addUnreadMessageToCounter, replaceThreadLocalStateParam } from '../../reducers/threads'; import { selectChat, selectChatFullInfo, @@ -40,9 +39,8 @@ import { selectIsChatListed, selectPeer, selectTabState, - selectThreadParam, - selectTopicFromMessage, } from '../../selectors'; +import { selectThreadLocalStateParam, selectThreadReadState } from '../../selectors/threads'; const TYPING_STATUS_CLEAR_DELAY = 6000; // 6 seconds const INVALIDATE_FULL_CHAT_FIELDS = new Set([ @@ -54,16 +52,16 @@ 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) { + const localReadState = selectThreadReadState(global, update.id, MAIN_THREAD_ID); + const { isForum: prevIsForum } = localChat || {}; + const { lastReadOutboxMessageId } = localReadState || {}; + if (update.readState?.lastReadOutboxMessageId && lastReadOutboxMessageId + && update.readState.lastReadOutboxMessageId < lastReadOutboxMessageId) { update = { ...update, - chat: omit(update.chat, ['lastReadInboxMessageId']), + readState: omit(update.readState, ['lastReadOutboxMessageId']), }; } - global = updateChat(global, update.id, update.chat); if (localChat?.areStoriesHidden !== update.chat.areStoriesHidden) { @@ -91,7 +89,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { if (update.chat.id) { closeMessageNotifications({ chatId: update.chat.id, - lastReadInboxMessageId: update.chat.lastReadInboxMessageId, + lastReadInboxMessageId: update.readState?.lastReadInboxMessageId, }); } @@ -159,34 +157,16 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { return global; } - case 'updateChatInbox': { - const { id, threadId, lastReadInboxMessageId, unreadCount } = update; - const chat = selectChat(global, id); - if (chat?.isBotForum && threadId) { - global = updateTopic(global, id, Number(threadId), { - unreadCount, - }); - return updateThreadInfo(global, id, threadId, { - lastReadInboxMessageId, - }); - } else { - return updateChat(global, id, { - lastReadInboxMessageId, - unreadCount, - }); - } - } - case 'updateChatTypingStatus': { const { id, threadId = MAIN_THREAD_ID, typingStatus } = update; - global = replaceThreadParam(global, id, threadId, 'typingStatus', typingStatus); + global = replaceThreadLocalStateParam(global, id, threadId, 'typingStatus', typingStatus); setGlobal(global); setTimeout(() => { global = getGlobal(); - const currentTypingStatus = selectThreadParam(global, id, threadId, 'typingStatus'); + const currentTypingStatus = selectThreadLocalStateParam(global, id, threadId, 'typingStatus'); if (typingStatus && currentTypingStatus && typingStatus.timestamp === currentTypingStatus.timestamp) { - global = replaceThreadParam(global, id, threadId, 'typingStatus', undefined); + global = replaceThreadLocalStateParam(global, id, threadId, 'typingStatus', undefined); setGlobal(global); } }, TYPING_STATUS_CLEAR_DELAY); @@ -195,39 +175,32 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } case 'newMessage': { - const { message } = update; + const { chatId, id, message } = update; const isOur = message.senderId ? message.senderId === global.currentUserId : message.isOutgoing; if (isOur && !message.isFromScheduled) { return undefined; } - const isLocal = isLocalMessageId(message.id!); + const isLocal = isLocalMessageId(id); - const chat = selectChat(global, update.chatId); + const chat = selectChat(global, chatId); if (!chat) { return undefined; } - const hasMention = Boolean(update.message.id && update.message.hasUnreadMention); + const hasMention = Boolean(message.hasUnreadMention); if (!isLocal || chat.id === SERVICE_NOTIFICATIONS_USER_ID) { - global = updateChat(global, update.chatId, { - unreadCount: chat.unreadCount ? chat.unreadCount + 1 : 1, - }); + global = addUnreadMessageToCounter(global, chatId, message); 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, + global = addUnreadMentions({ + global, + chatId, + ids: [id], }); } - - // TODO Replace draft with new message } setGlobal(global); @@ -246,18 +219,17 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { 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.reactions && !checkIfHasUnreadReactions(global, messageUpdate.reactions)) { + global = removeUnreadReactions({ global, chatId, ids: [id] }); } - if (!messageUpdate.hasUnreadMention && chat?.unreadMentionsCount) { - global = removeUnreadMentions(global, chatId, chat, [id], true); + if (!messageUpdate.hasUnreadMention) { + global = removeUnreadMentions({ + global, + chatId, + ids: [id], + }); } }); @@ -481,8 +453,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { return undefined; } - global = replaceThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'draft', draft); - global = updateChat(global, chatId, { draftDate: draft?.date }); + global = replaceThreadLocalStateParam(global, chatId, threadId || MAIN_THREAD_ID, 'draft', draft); return global; } diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 45f93223b..cac992d0b 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -1,5 +1,5 @@ import type { - ApiChat, ApiMediaExtendedPreview, ApiMessage, ApiReactions, + ApiMediaExtendedPreview, ApiMessage, ApiReactions, MediaContent, } from '../../../api/types'; import type { ActiveEmojiInteraction, ThreadId } from '../../../types'; @@ -14,7 +14,7 @@ import { areDeepEqual } from '../../../util/areDeepEqual'; import { isUserId } from '../../../util/entities/ids'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { - buildCollectionByKey, omit, pickTruthy, unique, + buildCollectionByKey, omit, unique, } from '../../../util/iteratees'; import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey'; import { notifyAboutMessage } from '../../../util/notifications'; @@ -28,6 +28,7 @@ import { getIsSavedDialog, getMessageContent, getMessageText, + groupMessageIdsByThreadId, isActionMessage, isMessageLocal, } from '../../helpers'; @@ -49,9 +50,7 @@ import { deleteQuickReplyMessages, deleteTopic, removeChatFromChatLists, - replaceThreadParam, replaceWebPage, - updateChat, updateChatLastMessageId, updateChatMediaLoadingState, updateChatMessage, @@ -63,13 +62,17 @@ import { updateQuickReplies, updateQuickReplyMessage, updateScheduledMessage, - updateThreadInfo, - updateThreadInfos, - updateThreadUnreadFromForwardedMessage, - updateTopic, } from '../../reducers'; -import { updateUnreadReactions } from '../../reducers/reactions'; +import { addUnreadReactions, removeUnreadReactions } from '../../reducers/reactions'; import { updateTabState } from '../../reducers/tabs'; +import { + replaceThreadLocalStateParam, + replaceThreadReadStateParam, + updateThreadInfo, + updateThreadInfoLastMessageId, + updateThreadInfoMessagesCount, + updateThreadReadState, +} from '../../reducers/threads'; import { selectCanAnimateSnapEffect, selectChat, @@ -88,19 +91,22 @@ import { selectListedIds, selectPerformanceSettingsValue, selectPinnedIds, - selectSavedDialogIdFromMessage, selectScheduledIds, selectScheduledMessage, selectTabState, - selectThread, - selectThreadByMessage, - selectThreadIdFromMessage, - selectThreadInfo, - selectThreadParam, selectTopic, selectTopicFromMessage, selectViewportIds, } from '../../selectors'; +import { + selectSavedDialogIdFromMessage, + selectThread, + selectThreadByMessage, + selectThreadIdFromMessage, + selectThreadInfo, + selectThreadLocalStateParam, + selectThreadReadState, +} from '../../selectors/threads'; const ANIMATION_DELAY = 350; const SNAP_ANIMATION_DELAY = 1000; @@ -114,7 +120,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { chatId, id, message, shouldForceReply, wasDrafted, poll, webPage, } = update; global = updateWithLocalMedia(global, chatId, id, true, message); - global = updateListedAndViewportIds(global, actions, message as ApiMessage); + global = updateListedAndViewportIds(global, message); const newMessage = selectChatMessage(global, chatId, id)!; const replyInfo = getMessageReplyInfo(newMessage); @@ -127,7 +133,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.loadTopicById({ chatId, topicId: replyInfo.replyToMsgId }); } - const isLocal = isMessageLocal(message as ApiMessage); + const isLocal = isMessageLocal(message); Object.values(global.byTabId).forEach(({ id: tabId }) => { // Force update for last message on drafted messages to prevent flickering @@ -138,7 +144,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { const threadId = selectThreadIdFromMessage(global, newMessage); global = updateChatMediaLoadingState(global, newMessage, chatId, threadId, tabId); - if (selectIsMessageInCurrentMessageList(global, chatId, message as ApiMessage, tabId)) { + if (selectIsMessageInCurrentMessageList(global, chatId, message, tabId)) { if (isLocal && message.isOutgoing && !(message.content?.action) && !storyReplyInfo?.storyId && !message.content?.storyData) { const currentMessageList = selectCurrentMessageList(global, tabId); @@ -147,7 +153,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.focusMessage({ chatId, threadId: currentMessageList.threadId, - messageId: message.id!, + messageId: message.id, noHighlight: true, isResizingContainer: true, tabId, @@ -189,10 +195,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { if (chat?.isBotForum && !newMessage.isOutgoing && !isLocal) { const threadId = selectThreadIdFromMessage(global, newMessage); - const typingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); + const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const localDraftIds = Object.values(typingDraftStore || {}); global = deleteChatMessages(global, chatId, localDraftIds); - global = replaceThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId', undefined); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId', undefined); } setGlobal(global); @@ -256,12 +262,16 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = updateWithLocalMedia(global, chatId, id, true, message, true); const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || []; - global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', unique([...scheduledIds, id])); + global = replaceThreadLocalStateParam( + 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])); + global = replaceThreadLocalStateParam( + global, chatId, threadId, 'scheduledIds', unique([...threadScheduledIds, id]), + ); } if (poll) { @@ -303,12 +313,14 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = updateWithLocalMedia(global, chatId, id, false, 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); + global = replaceThreadLocalStateParam(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)); + global = replaceThreadLocalStateParam( + global, chatId, threadId, 'scheduledIds', [...threadScheduledIds].sort((a, b) => b - a), + ); } if (poll) { global = updatePoll(global, poll.id, poll); @@ -325,13 +337,31 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { case 'updateMessage': { const { - chatId, id, message, poll, webPage, isFromNew, shouldForceReply, + chatId, id, message, poll, webPage, isFromNew, isFull, shouldForceReply, } = update; const currentMessage = selectChatMessage(global, chatId, id); + if (message.reactions) { + global = updateReactions( + global, actions, { + chatId, + id, + reactions: message.reactions, + }, + ); + } + + if (poll) { + global = updatePoll(global, poll.id, poll); + } + + if (webPage) { + global = replaceWebPage(global, webPage.id, webPage); + } + if (!currentMessage) { - if (isFromNew) { + if (isFromNew && isFull) { actions.apiUpdate({ '@type': 'newMessage', id: update.id, @@ -342,33 +372,21 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { shouldForceReply, }); } + + // If update contains the full message, store it + if (update.isFull) { + global = addMessages(global, [update.message]); + } + setGlobal(global); return; } - const chat = selectChat(global, chatId); - - global = updateWithLocalMedia(global, chatId, id, false, 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); global = clearMessageSummary(global, chatId, id); } - if (poll) { - global = updatePoll(global, poll.id, poll); - } - - if (webPage) { - global = replaceWebPage(global, webPage.id, webPage); - } + global = updateWithLocalMedia(global, chatId, id, false, message); setGlobal(global); @@ -465,7 +483,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { chatId, localId, message, poll, } = update; - global = updateListedAndViewportIds(global, actions, message); + global = updateListedAndViewportIds(global, message); const currentMessage = selectChatMessage(global, chatId, localId); @@ -509,17 +527,12 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { 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 = replaceThreadReadStateParam( + global, chatId, thread.threadInfo.threadId, 'lastReadInboxMessageId', message.id, + ); + global = updateThreadInfoLastMessageId(global, chatId, thread.threadInfo.threadId, 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)) { @@ -543,12 +556,16 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { chatId, localId, message, poll, } = update; const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || []; - global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', [...scheduledIds, message.id]); + global = replaceThreadLocalStateParam( + 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]); + global = replaceThreadLocalStateParam( + global, chatId, threadId, 'scheduledIds', [...threadScheduledIds, message.id], + ); } const currentMessage = selectScheduledMessage(global, chatId, localId); @@ -572,27 +589,14 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { case 'updatePinnedIds': { const { chatId, isPinned, messageIds } = update; - const messages = pickTruthy(selectChatMessages(global, chatId), messageIds); - const updatePerThread: Record = { - [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; - }); + const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, messageIds, false); - Object.entries(updatePerThread).forEach(([threadId, ids]) => { - const pinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID) || []; + Object.entries(messageIdsByThreadId).forEach(([threadId, ids]) => { + const pinnedIds = selectPinnedIds(global, chatId, threadId) || []; 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); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'pinnedIds', newPinnedIds); }); setGlobal(global); @@ -604,36 +608,18 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { threadInfo, } = update; - global = updateThreadInfos(global, [threadInfo]); + global = updateThreadInfo(global, threadInfo); setGlobal(global); - global = getGlobal(); - const { chatId, threadId } = threadInfo; - if (!chatId || !threadId) return; + break; + } - 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, - }); - } - } + case 'updateThreadReadState': { + const { + chatId, threadId, readState, + } = update; - // 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), - }); - } - } + global = updateThreadReadState(global, chatId, threadId, readState); setGlobal(global); break; @@ -803,13 +789,11 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } case 'updateMessageReactions': { - const { chatId, id, reactions } = update; - const message = selectChatMessage(global, chatId, id); - const chat = selectChat(global, update.chatId); + const { chatId, id, threadId, reactions } = update; - if (!chat || !message) return; - - global = updateReactions(global, actions, chatId, id, reactions, chat, message.isOutgoing, message); + global = updateReactions(global, actions, { + chatId, id, threadId, reactions, + }); setGlobal(global); break; } @@ -939,7 +923,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { const thread = selectThread(global, chatId, threadId); if (!thread) return undefined; - let typingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); + let typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const messageId = typingDraftStore?.[id]; const isUpdatingDraft = Boolean(messageId); @@ -949,7 +933,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { // Clear typing draft after timeout setTimeout(() => { global = getGlobal(); - const currentTypingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); + const currentTypingDraftStore = selectThreadLocalStateParam( + global, chatId, threadId, 'typingDraftIdByRandomId', + ); if (currentTypingDraftStore?.[id]) { const currentMessageId = currentTypingDraftStore[id]; const currentMessage = selectChatMessage(global, chatId, currentMessageId); @@ -957,7 +943,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { if (!currentMessage || getServerTime() - currentMessage.editDate! < global.appConfig.typingDraftTtl) return; const newTypingDraftIds = omit(currentTypingDraftStore, [id]); - global = replaceThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId', newTypingDraftIds); + global = replaceThreadLocalStateParam( + global, chatId, threadId, 'typingDraftIdByRandomId', newTypingDraftIds, + ); global = deleteChatMessages(global, chatId, [currentMessageId]); setGlobal(global); } @@ -998,7 +986,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { ...typingDraftStore, [id]: newMessage.id, }; - global = replaceThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId', typingDraftStore); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId', typingDraftStore); rescheduleDraftRemoval(); @@ -1010,13 +998,30 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { function updateReactions( global: T, actions: RequiredGlobalActions, - chatId: string, - id: number, - reactions: ApiReactions, - chat: ApiChat, - isOutgoing?: boolean, - message?: ApiMessage, + { + chatId, id, threadId, reactions, + }: { + chatId: string; + id: number; + threadId?: ThreadId; + reactions: ApiReactions; + }, ): T { + const chat = selectChat(global, chatId); + const message = selectChatMessage(global, chatId, id); + + if (!chat || !message) { + // Simplified logic to update counter only + const hasUnread = checkIfHasUnreadReactions(global, reactions); + if (hasUnread) { + global = addUnreadReactions({ global, chatId, ids: [id] }); + } else { + // Reload unread reactions to update counter + actions.loadUnreadReactions({ chatId, threadId }); + } + return global; + } + const currentReactions = message?.reactions; // `updateMessageReactions` happens with an interval, so we try to avoid redundant global state updates @@ -1033,7 +1038,7 @@ function updateReactions( global = updateChatMessage(global, chatId, id, { reactions }); - if (!isOutgoing) { + if (!message.isOutgoing) { return global; } @@ -1045,15 +1050,12 @@ function updateReactions( actions.startActiveReaction({ containerId: messageKey, reaction, tabId: getCurrentTabId() }); } - const hasUnreadReactionsForMessageInChat = chat.unreadReactions?.includes(id); + const hasUnreadReactionsForMessageInChat = message.reactions && checkIfHasUnreadReactions(global, message.reactions); 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), - }); + global = addUnreadReactions({ global, chatId, ids: [id] }); const newMessage = selectChatMessage(global, chatId, id); @@ -1069,10 +1071,7 @@ function updateReactions( } if (!hasUnreadReactionsInNewReactions && hasUnreadReactionsForMessageInChat) { - global = updateUnreadReactions(global, chatId, { - unreadReactionsCount: (chat?.unreadReactionsCount || 1) - 1, - unreadReactions: chat?.unreadReactions?.filter((i) => i !== id), - }); + global = removeUnreadReactions({ global, chatId, ids: [id] }); } return global; @@ -1117,48 +1116,17 @@ export function updateWithLocalMedia( : updateChatMessage(global, chatId, id, newMessage); } -function updateThreadUnread( - 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( - global: T, actions: RequiredGlobalActions, message: ApiMessage, + global: T, message: ApiMessage, ) { const { id, chatId } = message; const savedDialogId = selectSavedDialogIdFromMessage(global, message); + const threadId = savedDialogId || selectThreadIdFromMessage(global, message); + const threadInfo = selectThreadInfo(global, chatId, threadId); - 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 }; + const mainThreadReadState = selectThreadReadState(global, chatId, MAIN_THREAD_ID); + const isUnreadChatNotLoaded = mainThreadReadState?.unreadCount && !selectListedIds(global, chatId, MAIN_THREAD_ID); if (threadId) { global = updateListedIds(global, chatId, threadId, [id]); @@ -1177,15 +1145,11 @@ function updateListedAndViewportIds( }); if (threadInfo) { - global = replaceThreadParam(global, chatId, threadId, 'threadInfo', { - ...threadInfo, - lastMessageId: message.id, - }); + global = updateThreadInfoLastMessageId(global, chatId, threadId, message.id); if (!isMessageLocal(message) && !isActionMessage(message)) { - global = updateThreadInfo(global, chatId, threadId, { - messagesCount: (threadInfo.messagesCount || 0) + 1, - }); + const newCount = (threadInfo.messagesCount || 0) + 1; + global = updateThreadInfoMessagesCount(global, chatId, threadId, newCount); } } } @@ -1218,21 +1182,10 @@ function updateChatLastMessage( 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 threadId = selectThreadIdFromMessage(global, message); - global = updateThreadInfo(global, chatId, threadId, { - lastMessageId: message.id, - }); + global = updateThreadInfoLastMessageId(global, chatId, threadId, message.id); const savedDialogId = selectSavedDialogIdFromMessage(global, message); if (savedDialogId) { @@ -1345,8 +1298,6 @@ export function deleteMessages( 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); @@ -1358,14 +1309,17 @@ export function deleteMessages( const idsSet = new Set(ids); threadIdsToUpdate.forEach((threadId) => { + if (chat.isForum && threadId !== MAIN_THREAD_ID) { + // Refresh unread count + actions.loadTopicById({ chatId, topicId: Number(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; } @@ -1373,15 +1327,7 @@ export function deleteMessages( 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, - }); - } + global = updateThreadInfoLastMessageId(global, chatId, threadId, newLastMessage.id); }); setGlobal(global); @@ -1466,10 +1412,6 @@ function deleteScheduledMessages( 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); } diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts index 8bdc25bb3..769cc284a 100644 --- a/src/global/actions/apiUpdaters/misc.ts +++ b/src/global/actions/apiUpdaters/misc.ts @@ -21,9 +21,9 @@ import { updatePeersWithStories, updatePoll, updateStealthMode, - updateThreadInfos, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; +import { updateThreadInfo } from '../../reducers/threads'; import { selectPeer, selectPeerStories, @@ -39,7 +39,11 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } = update; if (users) global = addUsers(global, users); if (chats) global = addChats(global, chats); - if (threadInfos) global = updateThreadInfos(global, threadInfos); + if (threadInfos) { + threadInfos.forEach((threadInfo) => { + global = updateThreadInfo(global, threadInfo); + }); + } if (polls) { polls.forEach((poll) => { global = updatePoll(global, poll.id, poll); @@ -305,7 +309,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { if (receiver) { actions.focusMessage({ chatId: receiver.id, - messageId: update.message.id!, + messageId: update.message.id, tabId, }); diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index 745646940..c8092384d 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -7,9 +7,10 @@ import { createMessageHashUrl } from '../../../util/routing'; import { addActionHandler, execAfterActions, getGlobal, setGlobal } from '../../index'; import { closeMiddleSearch, - exitMessageSelectMode, replaceTabThreadParam, updateCurrentMessageList, updateRequestedChatTranslation, + exitMessageSelectMode, updateCurrentMessageList, updateRequestedChatTranslation, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; +import { replaceTabThreadParam } from '../../reducers/threads'; import { selectChat, selectCurrentMessageList, selectTabState, } from '../../selectors'; diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index aae3af383..3edb08616 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -37,12 +37,11 @@ import { cancelMessageMediaDownload, enterMessageSelectMode, exitMessageSelectMode, - replaceTabThreadParam, - replaceThreadParam, toggleMessageSelection, updateFocusedMessage, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; +import { replaceTabThreadParam, replaceThreadLocalStateParam } from '../../reducers/threads'; import { selectAllowedMessageActionsSlow, selectCanForwardMessage, @@ -53,20 +52,18 @@ import { selectChatScheduledMessages, selectCurrentChat, selectCurrentMessageList, - selectDraft, selectForwardedMessageIdsByGroupId, selectIsRightColumnShown, selectIsViewportNewest, selectMessageIdsByGroupId, - selectReplyStack, selectRequestedChatTranslationLanguage, selectRequestedMessageTranslationLanguage, selectSender, selectTabState, - selectThreadInfo, selectViewportIds, } from '../../selectors'; import { selectMessageDownloadableMedia } from '../../selectors/media'; +import { selectDraft, selectReplyStack, selectThreadInfo } from '../../selectors/threads'; import { getPeerStarsForMessage } from '../api/messages'; import { getIsMobile } from '../../../hooks/useAppLayout'; @@ -84,7 +81,7 @@ addActionHandler('setScrollOffset', (global, actions, payload): ActionReturnType chatId, threadId, scrollOffset, tabId = getCurrentTabId(), } = payload; - global = replaceThreadParam(global, chatId, threadId, 'lastScrollOffset', scrollOffset); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'lastScrollOffset', scrollOffset); return replaceTabThreadParam(global, chatId, threadId, 'scrollOffset', scrollOffset, tabId); }); @@ -99,7 +96,7 @@ addActionHandler('setEditingId', (global, actions, payload): ActionReturnType => const { chatId, threadId, type } = currentMessageList; const paramName = type === 'scheduled' ? 'editingScheduledId' : 'editingId'; - return replaceThreadParam(global, chatId, threadId, paramName, messageId); + return replaceThreadLocalStateParam(global, chatId, threadId, paramName, messageId); }); addActionHandler('setEditingDraft', (global, actions, payload): ActionReturnType => { @@ -109,7 +106,7 @@ addActionHandler('setEditingDraft', (global, actions, payload): ActionReturnType const paramName = type === 'scheduled' ? 'editingScheduledDraft' : 'editingDraft'; - return replaceThreadParam(global, chatId, threadId, paramName, text); + return replaceThreadLocalStateParam(global, chatId, threadId, paramName, text); }); addActionHandler('editLastMessage', (global, actions, payload): ActionReturnType => { @@ -133,7 +130,7 @@ addActionHandler('editLastMessage', (global, actions, payload): ActionReturnType return undefined; } - return replaceThreadParam(global, chatId, threadId, 'editingId', lastOwnEditableMessageId); + return replaceThreadLocalStateParam(global, chatId, threadId, 'editingId', lastOwnEditableMessageId); }); addActionHandler('replyToNextMessage', (global, actions, payload): ActionReturnType => { diff --git a/src/global/cache.ts b/src/global/cache.ts index e10b6f85f..15d16a8b7 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -33,6 +33,7 @@ import { GLOBAL_STATE_CACHE_KEY } from '../util/multiaccount'; import { encryptSession } from '../util/passcode'; import { onBeforeUnload, throttle } from '../util/schedulers'; import { hasStoredSession } from '../util/sessions'; +import { selectThreadInfo } from './selectors/threads'; import { addActionHandler, getGlobal } from './index'; import { INITIAL_GLOBAL_STATE, INITIAL_PERFORMANCE_STATE_MED } from './initialState'; import { clearGlobalForLockScreen, clearSharedStateForLockScreen } from './reducers'; @@ -42,6 +43,7 @@ import { selectCurrentMessageList, selectFullWebPageFromMessage, selectTopics, + selectTopicsInfo, selectViewportIds, selectVisibleUsers, } from './selectors'; @@ -364,6 +366,12 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { untypedCached.sharedState.settings.shouldWarnAboutSvg = undefined; } + if (cached.cacheVersion < 3) { + cached.cacheVersion = 3; + cached.messages = initialState.messages; + cached.chats.listIds = initialState.chats.listIds; + } + if (!cached.auth) { cached.auth = initialState.auth; cached.auth.rememberMe = untypedCached.rememberMe; @@ -674,18 +682,20 @@ function reduceMessages(global: T): GlobalState['messages const chatLastMessageId = selectChatLastMessageId(global, chatId); + const topicsInfo = selectTopicsInfo(global, chatId); const openedThreadIds = Array.from(openedChatThreadIds[chatId] || []); const commentThreadIds = Object.values(global.messages.byChatId[chatId].threadsById || {}) .map(({ threadInfo }) => (threadInfo?.isCommentsInfo ? threadInfo?.originMessageId : undefined)) .filter(Boolean); - const threadIds = unique(openedThreadIds.concat(commentThreadIds)); + const threadIds = unique(openedThreadIds.concat(commentThreadIds, topicsInfo?.listedTopicIds || [])); + const topics = selectTopics(global, chatId); const threadsToSave = pickTruthy(current.threadsById, [MAIN_THREAD_ID, ...threadIds]); - const viewportIdsToSave = unique(Object.values(threadsToSave).flatMap((thread) => thread.lastViewportIds || [])); - const topics = selectTopics(global, chatId); + const viewportIdsToSave = unique(Object.values(threadsToSave) + .flatMap((thread) => thread.localState?.lastViewportIds || [])); const topicLastMessageIds = topics && forumPanelChatIds.includes(chatId) - ? Object.values(topics).map(({ lastMessageId }) => lastMessageId) : []; + ? Object.values(topics).map(({ id }) => selectThreadInfo(global, chatId, id)?.lastMessageId).filter(Boolean) : []; const savedLastMessageIds = chatId === currentUserId && global.chats.lastMessageIds.saved ? Object.values(global.chats.lastMessageIds.saved) : []; const lastMessageIdsToSave = [chatLastMessageId].concat(topicLastMessageIds).concat(savedLastMessageIds) @@ -695,9 +705,11 @@ function reduceMessages(global: T): GlobalState['messages const thread = threadsToSave[Number(key)]; acc[Number(key)] = { ...thread, - listedIds: thread.lastViewportIds, - pinnedIds: undefined, - typingStatus: undefined, + localState: { + ...thread.localState, + listedIds: thread.localState?.lastViewportIds, + typingStatus: undefined, + }, }; return acc; }, {} as GlobalState['messages']['byChatId'][string]['threadsById']); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index 36ffedf0d..4533abc13 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -9,6 +9,7 @@ import type { ApiPeer, ApiPeerColorCollectible, ApiPreparedInlineMessage, + ApiThreadInfo, ApiTopic, } from '../../api/types'; import type { OldLangFn } from '../../hooks/useOldLang'; @@ -346,20 +347,28 @@ export function isChatPublic(chat: ApiChat) { } export function getOrderedTopics( - topics: ApiTopic[], pinnedOrder?: number[], shouldSortByLastMessage = false, + topics: ApiTopic[], + topicThreadInfos?: Record, + pinnedOrder?: number[], + shouldSortByLastMessage = false, ): ApiTopic[] { + const lastMessageIdComparator = (a: ApiTopic, b: ApiTopic) => ( + (topicThreadInfos?.[b.id]?.lastMessageId || 0) - (topicThreadInfos?.[a.id]?.lastMessageId || 0) + ); + if (shouldSortByLastMessage) { - return topics.sort((a, b) => b.lastMessageId - a.lastMessageId); + return topics.sort(lastMessageIdComparator); } else { const pinned = topics.filter((topic) => topic.isPinned); const ordered = topics .filter((topic) => !topic.isPinned && !topic.isHidden) - .sort((a, b) => b.lastMessageId - a.lastMessageId); + .sort(lastMessageIdComparator); const hidden = topics.filter((topic) => !topic.isPinned && topic.isHidden) - .sort((a, b) => b.lastMessageId - a.lastMessageId); + .sort(lastMessageIdComparator); const pinnedOrdered = pinnedOrder - ? pinnedOrder.map((id) => pinned.find((topic) => topic.id === id)).filter(Boolean) : pinned; + ? pinnedOrder.map((id) => pinned.find((topic) => topic.id === id)).filter(Boolean) + : pinned; return [...pinnedOrdered, ...ordered, ...hidden]; } diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 96286c194..07b4af041 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -35,7 +35,13 @@ import { areSortedArraysIntersecting, unique } from '../../util/iteratees'; import { isLocalMessageId } from '../../util/keys/messageKey'; import { getServerTime } from '../../util/serverTime'; import { getGlobal } from '../index'; -import { selectPollFromMessage, selectWebPageFromMessage } from '../selectors'; +import { + selectChatMessage, + selectPollFromMessage, + selectScheduledMessage, + selectWebPageFromMessage, +} from '../selectors'; +import { selectThreadIdFromMessage } from '../selectors/threads'; import { getMainUsername } from './users'; const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); @@ -546,3 +552,24 @@ export function createApiMessageFromTypingDraft({ editDate: getServerTime(), }; } + +export function groupMessageIdsByThreadId( + global: GlobalState, chatId: string, messageIds: number[], isScheduled: boolean, notShared?: boolean, +): Record { + const grouped = messageIds.reduce>((acc, messageId) => { + const message = isScheduled ? selectScheduledMessage(global, chatId, messageId) + : selectChatMessage(global, chatId, messageId); + if (!message) return acc; + + const threadId = selectThreadIdFromMessage(global, message); + acc[threadId] = acc[threadId] || []; + acc[threadId].push(messageId); + return acc; + }, {}); + + if (!notShared) { + grouped[MAIN_THREAD_ID] = messageIds; + } + + return grouped; +} diff --git a/src/global/helpers/payments.ts b/src/global/helpers/payments.ts index dac43c3f7..e7a74139c 100644 --- a/src/global/helpers/payments.ts +++ b/src/global/helpers/payments.ts @@ -13,7 +13,7 @@ import type { LangFn } from '../../util/localization'; import type { GlobalState } from '../types'; import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../config'; -import arePropsShallowEqual from '../../util/arePropsShallowEqual'; +import { areRecordsShallowEqual } from '../../util/areShallowEqual'; import { convertTonFromNanos } from '../../util/formatCurrency'; import { selectChat, selectPeer, selectUser } from '../selectors'; @@ -494,5 +494,5 @@ export function getPrizeStarsTransactionFromGiveaway(message: ApiMessage): ApiSt } export function areInputSavedGiftsEqual(one: ApiInputSavedStarGift, two: ApiInputSavedStarGift) { - return arePropsShallowEqual(one, two); + return areRecordsShallowEqual(one, two); } diff --git a/src/global/init.ts b/src/global/init.ts index 6f35257a1..48702bbd5 100644 --- a/src/global/init.ts +++ b/src/global/init.ts @@ -13,14 +13,15 @@ import { parseLocationHash } from '../util/routing'; import { updatePeerColors } from '../util/theme'; import { initializeChatMediaSearchResults } from './reducers/middleSearch'; import { updateTabState } from './reducers/tabs'; +import { replaceTabThreadParam, replaceThreadLocalStateParam } from './reducers/threads'; +import { selectThreadLocalStateParam } from './selectors/threads'; import { initSharedState } from './shared/sharedStateConnector'; import { initCache } from './cache'; import { addActionHandler, getGlobal, setGlobal, } from './index'; import { INITIAL_TAB_STATE } from './initialState'; -import { replaceTabThreadParam, replaceThreadParam } from './reducers'; -import { selectTabState, selectThreadParam } from './selectors'; +import { selectTabState } from './selectors'; initCache(); @@ -63,10 +64,10 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => { const threadsById = global.messages.byChatId[chatId].threadsById; Object.keys(threadsById).forEach((thread) => { const threadId = Number(thread); - const lastViewportIds = selectThreadParam(global, chatId, threadId, 'lastViewportIds'); + const lastViewportIds = selectThreadLocalStateParam(global, chatId, threadId, 'lastViewportIds'); // Check if migration from previous version is faulty if (!lastViewportIds?.every((id) => isLocalMessageId(id) || global.messages.byChatId[chatId]?.byId[id])) { - global = replaceThreadParam(global, chatId, threadId, 'lastViewportIds', undefined); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'lastViewportIds', undefined); return; } global = initializeChatMediaSearchResults(global, chatId, threadId, tabId); @@ -85,10 +86,18 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => { Object.keys(global.messages.byChatId).forEach((chatId) => { const threadsById = global.messages.byChatId[chatId].threadsById; const fixedThreadsById = Object.keys(threadsById).reduce((acc, key) => { - const t = threadsById[Number(key)]; - acc[Number(key)] = { + const t = threadsById[key]; + if (!t.localState?.lastViewportIds) { + acc[key] = t; + return acc; + } + + acc[key] = { ...t, - listedIds: t.lastViewportIds, + localState: { + ...t.localState, + listedIds: t.localState.lastViewportIds, + }, }; return acc; }, {} as GlobalState['messages']['byChatId'][string]['threadsById']); diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 7057bb5da..b755fcd41 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -96,7 +96,7 @@ export const INITIAL_SHARED_STATE: SharedState = { }; export const INITIAL_GLOBAL_STATE: GlobalState = { - cacheVersion: 2, + cacheVersion: 3, isInited: true, attachMenu: { bots: {} }, passcode: {}, diff --git a/src/global/reducers/chats.ts b/src/global/reducers/chats.ts index 694769aff..7e7049ff8 100644 --- a/src/global/reducers/chats.ts +++ b/src/global/reducers/chats.ts @@ -1,15 +1,19 @@ -import type { - ApiChat, ApiChatFullInfo, ApiChatMember, -} from '../../api/types'; import type { ChatListType } from '../../types'; import type { GlobalState } from '../types'; +import { + type ApiChat, type ApiChatFullInfo, type ApiChatMember, + MAIN_THREAD_ID, +} from '../../api/types'; import { ARCHIVED_FOLDER_ID } from '../../config'; import { areDeepEqual } from '../../util/areDeepEqual'; import { areSortedArraysEqual, buildCollectionByKey, omit, omitUndefined, pick, unique, } from '../../util/iteratees'; +import { groupMessageIdsByThreadId } from '../helpers'; import { selectChatFullInfo } from '../selectors'; +import { selectThreadReadState } from '../selectors/threads'; +import { replaceThreadReadStateParam, updateThreadInfoLastMessageId } from './threads'; const DEFAULT_CHAT_LISTS: ChatListType[] = ['active', 'archived']; @@ -53,6 +57,11 @@ export function updateChatLastMessageId( global: T, chatId: string, lastMessageId: number, listType?: ChatListType, ): T { const key = listType === 'saved' ? 'saved' : 'all'; + + if (key === 'all') { + global = updateThreadInfoLastMessageId(global, chatId, MAIN_THREAD_ID, lastMessageId); + } + return { ...global, chats: { @@ -115,49 +124,67 @@ export function replaceChats(global: T, newById: Record( - global: T, chatId: string, chat: ApiChat, ids: number[], shouldUpdateCount: boolean = false, -): T { - const prevChatUnreadMentions = (chat.unreadMentions || []); - const updatedUnreadMentions = unique([...prevChatUnreadMentions, ...ids]).sort((a, b) => b - a); - global = updateChat(global, chatId, { - unreadMentions: updatedUnreadMentions, - }); +export function addUnreadMentions({ + global, chatId, ids, totalCount, +}: { + global: T; + chatId: string; + ids: number[]; + totalCount?: number; +}): T { + const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, ids, false); - if (shouldUpdateCount) { - const updatedUnreadMentionsCount = (chat.unreadMentionsCount || 0) + for (const threadId in messageIdsByThreadId) { + const messageIds = messageIdsByThreadId[threadId]; + if (totalCount !== undefined) { // Assume that when `totalCount` is passed, server returned full id list + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentions', messageIds); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentionsCount', totalCount); + continue; + } + + const readState = selectThreadReadState(global, chatId, threadId); + const prevChatUnreadMentions = readState?.unreadMentions || []; + const updatedUnreadMentions = unique([...prevChatUnreadMentions, ...messageIds]).sort((a, b) => b - a); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentions', updatedUnreadMentions); + + const updatedUnreadMentionsCount = (readState?.unreadMentionsCount || 0) + Math.max(0, updatedUnreadMentions.length - prevChatUnreadMentions.length); - global = updateChat(global, chatId, { - unreadMentionsCount: updatedUnreadMentionsCount, - }); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentionsCount', updatedUnreadMentionsCount); } + return global; } -export function removeUnreadMentions( - global: T, chatId: string, chat: ApiChat, ids: number[], shouldUpdateCount: boolean = false, -): T { - const prevChatUnreadMentions = (chat.unreadMentions || []); - const updatedUnreadMentions = prevChatUnreadMentions?.filter((id) => !ids.includes(id)); +export function removeUnreadMentions({ + global, chatId, ids, +}: { + global: T; + chatId: string; + ids: number[]; +}): T { + const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, ids, false); - global = updateChat(global, chatId, { - unreadMentions: updatedUnreadMentions, - }); + for (const threadId in messageIdsByThreadId) { + const messageIds = messageIdsByThreadId[threadId]; + const readState = selectThreadReadState(global, chatId, threadId); + const prevChatUnreadMentions = (readState?.unreadMentions || []); + const updatedUnreadMentions = prevChatUnreadMentions?.filter((id) => !messageIds.includes(id)); + + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentions', updatedUnreadMentions); - if (shouldUpdateCount && chat.unreadMentionsCount) { const removedCount = prevChatUnreadMentions.length - updatedUnreadMentions.length; - const updatedUnreadMentionsCount = Math.max(chat.unreadMentionsCount - removedCount, 0) || undefined; + if (removedCount && readState?.unreadMentionsCount) { + const updatedUnreadMentionsCount = Math.max(readState.unreadMentionsCount - removedCount, 0); - global = updateChat(global, chatId, { - unreadMentionsCount: updatedUnreadMentionsCount, - }); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentionsCount', updatedUnreadMentionsCount); + } } return global; } export function updateChat( - global: T, chatId: string, chatUpdate: Partial, noOmitUnreadReactionCount = false, withDeepCheck = false, + global: T, chatId: string, chatUpdate: Partial, withDeepCheck = false, ): T { const { byId } = global.chats; @@ -169,7 +196,7 @@ export function updateChat( } } - const updatedChat = getUpdatedChat(global, chatId, chatUpdate, noOmitUnreadReactionCount); + const updatedChat = getUpdatedChat(global, chatId, chatUpdate); if (!updatedChat) { return global; } @@ -284,7 +311,6 @@ export function addChats(global: T, newById: Record( global: T, chatId: string, chatUpdate: Partial, - noOmitUnreadReactionCount = false, ) { const { byId } = global.chats; const chat = byId[chatId]; @@ -294,10 +320,6 @@ function getUpdatedChat( return undefined; // Do not apply updates from min constructor } - if (!noOmitUnreadReactionCount) { - omitProps.push('unreadReactionsCount'); - } - if (areDeepEqual(chat?.usernames, chatUpdate.usernames)) { omitProps.push('usernames'); } diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index a8a0584be..ae8e9a171 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -1,14 +1,12 @@ import type { ApiFormattedText, - ApiMessage, ApiPoll, ApiPollResult, ApiQuickReply, ApiSponsoredMessage, ApiThreadInfo, + ApiMessage, ApiPoll, ApiPollResult, ApiQuickReply, ApiSponsoredMessage, ApiWebPage, ApiWebPageFull, } from '../../api/types'; import type { MessageList, MessageListType, - TabThread, - Thread, ThreadId, } from '../../types'; import type { @@ -30,6 +28,7 @@ import { unload } from '../../util/mediaLoader'; import { getAllMessageMediaHashes, getMessageStatefulContent, + groupMessageIdsByThreadId, hasMessageTtl, isMediaLoadableInViewer, mergeIdRanges, orderHistoryIds, orderPinnedIds, } from '../helpers'; import { getEmojiOnlyCountForMessage } from '../helpers/getEmojiOnlyCountForMessage'; @@ -45,17 +44,22 @@ import { selectPinnedIds, selectPoll, selectQuickReplyMessage, - selectScheduledIds, selectScheduledMessage, selectTabState, - selectThreadIdFromMessage, - selectThreadInfo, - selectThreadParam, selectViewportIds, selectWebPage, } from '../selectors'; +import { selectThreadIdFromMessage, selectThreadInfo, selectThreadLocalStateParam } from '../selectors/threads'; +import { removeUnreadMentions } from './chats'; import { removeIdFromSearchResults } from './middleSearch'; +import { removeUnreadReactions } from './reactions'; import { updateTabState } from './tabs'; +import { + deleteThread, + replaceTabThreadParam, + replaceThreadLocalStateParam, + updateThreadInfoMessagesCount, +} from './threads'; import { clearMessageSummary, clearMessageTranslation } from './translations'; type MessageStoreSections = GlobalState['messages']['byChatId'][string]; @@ -107,49 +111,6 @@ export function replaceChatMessages( }); } -export function updateTabThread( - global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial, - ...[tabId = getCurrentTabId()]: TabArgs -): T { - const tabState = selectTabState(global, tabId); - const current = tabState.tabThreads[chatId]?.[threadId] || {}; - - return updateTabState(global, { - tabThreads: { - ...tabState.tabThreads, - [chatId]: { - ...tabState.tabThreads[chatId], - [threadId]: { - ...current, - ...threadUpdate, - }, - }, - }, - }, tabId); -} - -export function updateThread( - global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial | undefined, -): T { - if (!threadUpdate) { - return updateMessageStore(global, chatId, { - threadsById: omit(global.messages.byChatId[chatId]?.threadsById, [threadId]), - }); - } - - const current = global.messages.byChatId[chatId]; - - return updateMessageStore(global, chatId, { - threadsById: { - ...(current?.threadsById), - [threadId]: { - ...(current?.threadsById[threadId]), - ...threadUpdate, - }, - }, - }); -} - export function updateMessageStore( global: T, chatId: string, update: Partial, ): T { @@ -171,24 +132,6 @@ export function updateMessageStore( }; } -export function replaceTabThreadParam( - global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: TabThread[K] | undefined, - ...[tabId = getCurrentTabId()]: TabArgs -) { - if (paramName === 'viewportIds') { - global = replaceThreadParam( - global, chatId, threadId, 'lastViewportIds', newValue as number[] | undefined, - ); - } - return updateTabThread(global, chatId, threadId, { [paramName]: newValue }, tabId); -} - -export function replaceThreadParam( - global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: Thread[K] | undefined, -) { - return updateThread(global, chatId, threadId, { [paramName]: newValue }); -} - export function addMessages( global: T, messages: ApiMessage[], ): T { @@ -462,14 +405,12 @@ export function deleteChatMessages( ); }); - global = replaceThreadParam(global, chatId, threadId, 'listedIds', listedIds); - global = replaceThreadParam(global, chatId, threadId, 'outlyingLists', outlyingLists); - global = replaceThreadParam(global, chatId, threadId, 'pinnedIds', pinnedIds); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'listedIds', listedIds); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'outlyingLists', outlyingLists); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'pinnedIds', pinnedIds); if (threadInfo && newMessageCount !== undefined) { - global = updateThreadInfo(global, chatId, threadId, { - messagesCount: newMessageCount, - }); + global = updateThreadInfoMessagesCount(global, chatId, threadId, newMessageCount); } }); @@ -488,12 +429,15 @@ export function deleteChatMessages( global = updateCurrentMessageList(global, chatId, undefined, undefined, undefined, undefined, tabId); } if (originalPost) { - global = updateThread(global, fromChatId!, fromMessageId!, undefined); + global = deleteThread(global, fromChatId!, fromMessageId!); } }); }); } + global = removeUnreadReactions({ global, chatId, ids: messageIds }); + global = removeUnreadMentions({ global, chatId, ids: messageIds }); + const newById = omit(byId, messageIds); global = replaceChatMessages(global, chatId, newById); @@ -511,22 +455,15 @@ export function deleteChatScheduledMessages( } const newById = omit(byId, messageIds); - let scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID); - if (scheduledIds) { - messageIds.forEach((messageId) => { - if (scheduledIds!.includes(messageId)) { - scheduledIds = scheduledIds!.filter((id) => id !== messageId); - } - }); - global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', scheduledIds); + const groupedByThreadId = groupMessageIdsByThreadId(global, chatId, messageIds, true); + Object.entries(groupedByThreadId).forEach(([tId, newThreadScheduledIds]) => { + const threadId = tId as ThreadId; + const scheduledIds = selectThreadLocalStateParam(global, chatId, threadId, 'scheduledIds'); + if (!scheduledIds?.length) return; - Object.entries(global.messages.byChatId[chatId].threadsById).forEach(([threadId, thread]) => { - if (thread.scheduledIds) { - const newScheduledIds = thread.scheduledIds.filter((id) => !messageIds.includes(id)); - global = replaceThreadParam(global, chatId, Number(threadId), 'scheduledIds', newScheduledIds); - } - }); - } + const cleanedScheduledIds = scheduledIds.filter((id) => !newThreadScheduledIds.includes(id)); + global = replaceThreadLocalStateParam(global, chatId, threadId, 'scheduledIds', cleanedScheduledIds); + }); global = { ...global, @@ -558,7 +495,7 @@ export function updateListedIds( return global; } - return replaceThreadParam(global, chatId, threadId, 'listedIds', orderHistoryIds([ + return replaceThreadLocalStateParam(global, chatId, threadId, 'listedIds', orderHistoryIds([ ...(listedIds || []), ...newIds, ])); @@ -577,7 +514,7 @@ export function removeOutlyingList( const newOutlyingLists = outlyingLists.filter((l) => l !== list); - return replaceThreadParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists); + return replaceThreadLocalStateParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists); } export function updateOutlyingLists( @@ -592,7 +529,7 @@ export function updateOutlyingLists( const newOutlyingLists = mergeIdRanges(outlyingLists || [], idsUpdate); - return replaceThreadParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists); + return replaceThreadLocalStateParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists); } export function addViewportId( @@ -648,7 +585,7 @@ export function safeReplacePinnedIds( const currentIds = selectPinnedIds(global, chatId, threadId) || []; const newIds = orderPinnedIds(newPinnedIds); - return replaceThreadParam( + return replaceThreadLocalStateParam( global, chatId, threadId, @@ -657,42 +594,6 @@ export function safeReplacePinnedIds( ); } -export function updateThreadInfo( - global: T, chatId: string, threadId: ThreadId, update: Partial | undefined, - doNotUpdateLinked?: boolean, -): T { - const newThreadInfo = { - ...(selectThreadInfo(global, chatId, threadId) as ApiThreadInfo), - ...update, - } as ApiThreadInfo; - - if (!doNotUpdateLinked && !newThreadInfo.isCommentsInfo) { - const linkedUpdate = pick(newThreadInfo, ['messagesCount', 'lastMessageId', 'lastReadInboxMessageId']); - if (newThreadInfo.fromChannelId && newThreadInfo.fromMessageId) { - global = updateThreadInfo( - global, newThreadInfo.fromChannelId, newThreadInfo.fromMessageId, linkedUpdate, true, - ); - } - } - - return replaceThreadParam(global, chatId, threadId, 'threadInfo', newThreadInfo); -} - -export function updateThreadInfos( - global: T, updates: Partial[], -): T { - updates.forEach((update) => { - global = updateThreadInfo( - global, - update.isCommentsInfo ? update.originChannelId! : update.chatId!, - update.isCommentsInfo ? update.originMessageId! : update.threadId!, - update, - ); - }); - - return global; -} - export function updateScheduledMessages( global: T, chatId: string, newById: Record, ): T { @@ -852,27 +753,6 @@ export function exitMessageSelectMode( }, tabId); } -export function updateThreadUnreadFromForwardedMessage( - global: T, - originMessage: ApiMessage, - chatId: string, - lastMessageId: number, - isDeleting?: boolean, -): T { - const { channelPostId, fromChatId } = originMessage.forwardInfo || {}; - if (channelPostId && fromChatId) { - const threadInfoOld = selectThreadInfo(global, chatId, channelPostId); - if (threadInfoOld) { - global = replaceThreadParam(global, chatId, channelPostId, 'threadInfo', { - ...threadInfoOld, - lastMessageId, - messagesCount: (threadInfoOld.messagesCount || 0) + (isDeleting ? -1 : 1), - }); - } - } - return global; -} - export function addActiveMediaDownload( global: T, mediaHash: string, @@ -1086,7 +966,7 @@ export function updateTypingDraft( randomId: string, text: ApiFormattedText, ) { - const typingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); + const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const messageId = typingDraftStore?.[randomId]; if (!messageId) { return global; diff --git a/src/global/reducers/reactions.ts b/src/global/reducers/reactions.ts index 176cb9cee..1c25651c9 100644 --- a/src/global/reducers/reactions.ts +++ b/src/global/reducers/reactions.ts @@ -1,16 +1,18 @@ -import type { ApiChat, ApiMessage, ApiReactionWithPaid } from '../../api/types'; import type { GlobalState } from '../types'; +import { type ApiMessage, type ApiReactionWithPaid } from '../../api/types'; import { MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN, MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config'; +import { unique } from '../../util/iteratees'; import windowSize from '../../util/windowSize'; import { MIN_LEFT_COLUMN_WIDTH, SIDE_COLUMN_MAX_WIDTH, } from '../../components/middle/helpers/calculateMiddleFooterTransforms'; -import { updateReactionCount } from '../helpers'; +import { groupMessageIdsByThreadId, updateReactionCount } from '../helpers'; import { selectIsChatWithSelf, selectSendAs, selectTabState } from '../selectors'; -import { updateChat } from './chats'; +import { selectThreadReadState } from '../selectors/threads'; import { updateChatMessage } from './messages'; +import { replaceThreadReadStateParam } from './threads'; import { getIsMobile } from '../../hooks/useAppLayout'; @@ -75,8 +77,60 @@ export function addMessageReaction( }); } -export function updateUnreadReactions( - global: T, chatId: string, update: Pick, -): T { - return updateChat(global, chatId, update, true); +export function addUnreadReactions({ + global, chatId, ids, totalCount, +}: { + global: T; + chatId: string; + ids: number[]; + totalCount?: number; +}): T { + const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, ids, false); + + for (const threadId in messageIdsByThreadId) { + const messageIds = messageIdsByThreadId[threadId]; + if (totalCount !== undefined) { // Assume that when `totalCount` is passed, server returned full id list + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactions', messageIds); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactionsCount', totalCount); + continue; + } + + const readState = selectThreadReadState(global, chatId, threadId); + const prevChatUnreadReactions = readState?.unreadReactions || []; + const updatedUnreadReactions = unique([...prevChatUnreadReactions, ...messageIds]).sort((a, b) => b - a); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactions', updatedUnreadReactions); + + const delta = updatedUnreadReactions.length - prevChatUnreadReactions.length; + if (delta > 0) { + const unreadReactionsCount = (readState?.unreadReactionsCount || 0) + delta; + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactionsCount', unreadReactionsCount); + } + } + + return global; +} + +export function removeUnreadReactions({ + global, chatId, ids, +}: { + global: T; + chatId: string; + ids: number[]; +}): T { + const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, ids, false); + + for (const threadId in messageIdsByThreadId) { + const messageIds = messageIdsByThreadId[threadId]; + const readState = selectThreadReadState(global, chatId, threadId); + const prevChatUnreadReactions = readState?.unreadReactions || []; + const updatedUnreadReactions = prevChatUnreadReactions.filter((id) => !messageIds.includes(id)); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactions', updatedUnreadReactions); + + const delta = prevChatUnreadReactions.length - updatedUnreadReactions.length; + if (delta > 0 && readState?.unreadReactionsCount) { + const unreadReactionsCount = Math.max(readState.unreadReactionsCount - delta, 0); + global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactionsCount', unreadReactionsCount); + } + } + return global; } diff --git a/src/global/reducers/threads.ts b/src/global/reducers/threads.ts new file mode 100644 index 000000000..cf239ea0e --- /dev/null +++ b/src/global/reducers/threads.ts @@ -0,0 +1,241 @@ +import type { ApiMessage, ApiThreadInfo } from '../../api/types'; +import type { TabThread, Thread, ThreadId, ThreadLocalState, ThreadReadState } from '../../types'; +import type { GlobalState, TabArgs } from '../types'; +import { MAIN_THREAD_ID } from '../../api/types'; + +import { getCurrentTabId } from '../../util/establishMultitabRole'; +import { omit, pick } from '../../util/iteratees'; +import { selectChatMessage, selectTabState } from '../selectors'; +import { selectThread, selectThreadIdFromMessage, selectThreadInfo, selectThreadReadState } from '../selectors/threads'; +import { updateMessageStore } from './messages'; +import { updateTabState } from './tabs'; + +export function replaceTabThreadParam( + global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: TabThread[K] | undefined, + ...[tabId = getCurrentTabId()]: TabArgs +) { + if (paramName === 'viewportIds') { + global = replaceThreadLocalStateParam( + global, chatId, threadId, 'lastViewportIds', newValue as number[] | undefined, + ); + } + return updateTabThread(global, chatId, threadId, { [paramName]: newValue }, tabId); +} + +export function replaceThreadLocalStateParam( + global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: ThreadLocalState[K] | undefined, +) { + return updateThreadLocalState(global, chatId, threadId, { [paramName]: newValue }); +} + +export function replaceThreadReadStateParam( + global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: ThreadReadState[K] | undefined, +) { + return updateThreadReadState(global, chatId, threadId, { [paramName]: newValue }); +} + +export function updateTabThread( + global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const tabState = selectTabState(global, tabId); + const current = tabState.tabThreads[chatId]?.[threadId] || {}; + + return updateTabState(global, { + tabThreads: { + ...tabState.tabThreads, + [chatId]: { + ...tabState.tabThreads[chatId], + [threadId]: { + ...current, + ...threadUpdate, + }, + }, + }, + }, tabId); +} + +export function updateThreadLocalState( + global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial | undefined, +): T { + const currentThread = selectThread(global, chatId, threadId); + if (!currentThread) return global; + + if (!threadUpdate && !currentThread.threadInfo) { + return updateMessageStore(global, chatId, { + threadsById: omit(global.messages.byChatId[chatId]?.threadsById, [threadId]), + }); + } + + const updated: ThreadLocalState = threadUpdate ? { + ...currentThread.localState, + ...threadUpdate, + } : {}; + + return updateMessageStore(global, chatId, { + threadsById: { + ...global.messages.byChatId[chatId]?.threadsById, + [threadId]: { + ...currentThread, + localState: updated, + }, + }, + }); +} + +export function updateThreadReadState( + global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial, +): T { + const currentThread = selectThread(global, chatId, threadId); + if (!currentThread) return global; + + const updated: ThreadReadState = { + ...currentThread.readState, + ...threadUpdate, + }; + + return updateMessageStore(global, chatId, { + threadsById: { + ...global.messages.byChatId[chatId]?.threadsById, + [threadId]: { + ...currentThread, + readState: updated, + }, + }, + }); +} + +export function updateThreadInfo( + global: T, update: Partial | undefined, doNotUpdateLinked?: boolean, +): T { + const chatId = update?.isCommentsInfo ? update.originChannelId : update?.chatId; + const threadId = update?.isCommentsInfo ? update.originMessageId : update?.threadId; + + if (!chatId || !threadId) { + return global; + } + + const currentThread = selectThread(global, chatId, threadId); + const newThreadInfo = { + ...currentThread?.threadInfo, + ...update, + } as ApiThreadInfo; + + if (!doNotUpdateLinked) { + global = updateLinkedThreadInfo(global, newThreadInfo); + } + + return updateThreadInfoInStore(global, chatId, threadId, newThreadInfo); +} + +export function updateLinkedThreadInfo( + global: T, update: ApiThreadInfo, +): T { + if (update.isCommentsInfo || !update.fromChannelId || !update.fromMessageId) { + return global; + } + + const threadInfo = selectThreadInfo(global, update.fromChannelId, update.fromMessageId); + if (!threadInfo) { + return global; + } + + const valuesToUpdate = pick(update, ['messagesCount', 'lastMessageId', 'recentReplierIds']); + const newThreadInfo: ApiThreadInfo = { + ...threadInfo, + ...valuesToUpdate, + }; + return updateThreadInfoInStore(global, update.fromChannelId, update.fromMessageId, newThreadInfo); +} + +export function updateThreadInfoInStore( + global: T, chatId: string, threadId: ThreadId, update: ApiThreadInfo, +): T { + const thread = selectThread(global, chatId, threadId); + + const newThread: Thread = { + localState: thread?.localState || {}, + readState: thread?.readState || {}, + threadInfo: update, + }; + + return updateMessageStore(global, chatId, { + threadsById: { + ...global.messages.byChatId[chatId]?.threadsById, + [threadId]: newThread, + }, + }); +} + +export function updateThreadInfoMessagesCount( + global: T, chatId: string, threadId: ThreadId, newCount: number, +): T { + const threadInfo = selectThreadInfo(global, chatId, threadId); + if (!threadInfo) return global; + + const newThreadInfo: ApiThreadInfo = { + ...threadInfo, + messagesCount: newCount, + }; + return updateThreadInfo(global, newThreadInfo); +} + +export function updateThreadInfoLastMessageId( + global: T, chatId: string, threadId: ThreadId, newLastMessageId: number | undefined, +): T { + const threadInfo = selectThreadInfo(global, chatId, threadId); + if (!threadInfo) return global; + + const newThreadInfo: ApiThreadInfo = { + ...threadInfo, + lastMessageId: newLastMessageId, + }; + return updateThreadInfo(global, newThreadInfo); +} + +export function addUnreadMessageToCounter( + global: T, chatId: string, message: ApiMessage, +): T { + const threadId = selectThreadIdFromMessage(global, message); + const currentReadState = selectThreadReadState(global, chatId, threadId); + const newUnreadCount = (currentReadState?.unreadCount || 0) + 1; + return replaceThreadReadStateParam(global, chatId, threadId, 'unreadCount', newUnreadCount); +} + +export function decrementUnreadCount( + global: T, chatId: string, messageId: number, amount: number, +): T { + const message = selectChatMessage(global, chatId, messageId); + if (!message) return global; + + const threadId = selectThreadIdFromMessage(global, message); + const currentReadState = selectThreadReadState(global, chatId, threadId); + const newUnreadCount = Math.max(0, (currentReadState?.unreadCount || 0) - amount); + return replaceThreadReadStateParam(global, chatId, threadId, 'unreadCount', newUnreadCount); +} + +export function updateMainThreadReadStates( + global: T, threadReadStates: Record, +): T { + Object.entries(threadReadStates).forEach(([chatId, readState]) => { + global = updateThreadReadState(global, chatId, MAIN_THREAD_ID, readState); + }); + return global; +} + +export function updateThreadReadStates( + global: T, chatId: string, threadReadStates: Record, +): T { + Object.entries(threadReadStates).forEach(([threadId, readState]) => { + global = updateThreadReadState(global, chatId, threadId, readState); + }); + return global; +} + +export function deleteThread( + global: T, chatId: string, threadId: ThreadId, +): T { + return updateMessageStore(global, chatId, { + threadsById: omit(global.messages.byChatId[chatId]?.threadsById, [threadId]), + }); +} diff --git a/src/global/reducers/topics.ts b/src/global/reducers/topics.ts index 04cbaa5e0..e7b3ac356 100644 --- a/src/global/reducers/topics.ts +++ b/src/global/reducers/topics.ts @@ -1,15 +1,30 @@ -import type { ApiTopic } from '../../api/types'; +import type { ApiTopic, ApiTopicWithState } from '../../api/types'; import type { TopicsInfo } from '../../types'; import type { GlobalState } from '../types'; -import { buildCollectionByKey, omit, unique } from '../../util/iteratees'; +import { omit, pick, unique } from '../../util/iteratees'; import { selectChat, selectTopic, selectTopics, selectTopicsInfo, } from '../selectors'; -import { updateThread, updateThreadInfo } from './messages'; +import { + updateThreadInfo, + updateThreadLocalState, + updateThreadReadState, +} from './threads'; -function updateTopicsStore( +const SAFE_MIN_PROPERTIES: (keyof ApiTopic)[] = [ + 'id', + 'title', + 'iconColor', + 'iconEmojiId', + 'date', + 'fromId', + 'isOwner', + 'isClosed', +]; + +export function updateTopicsInfo( global: T, chatId: string, update: Partial, ) { const info = global.chats.topicsInfoById[chatId] || {}; @@ -35,7 +50,7 @@ export function updateListedTopicIds( global: T, chatId: string, topicIds: number[], ): T { const listedIds = selectTopicsInfo(global, chatId)?.listedTopicIds || []; - return updateTopicsStore(global, chatId, { + return updateTopicsInfo(global, chatId, { listedTopicIds: unique([ ...listedIds, ...topicIds, @@ -43,35 +58,6 @@ export function updateListedTopicIds( }); } -export function updateTopics( - global: T, chatId: string, topicsCount: number, topics: ApiTopic[], -): T { - const oldTopics = selectTopics(global, chatId); - const newTopics = buildCollectionByKey(topics, 'id'); - - global = updateTopicsStore(global, chatId, { - topicsById: { - ...oldTopics, - ...newTopics, - }, - totalCount: topicsCount, - }); - - topics.forEach((topic) => { - global = updateThread(global, chatId, topic.id, { - firstMessageId: topic.id, - }); - - global = updateThreadInfo(global, chatId, topic.id, { - lastMessageId: topic.lastMessageId, - threadId: topic.id, - chatId, - }); - }); - - return global; -} - export function updateTopic( global: T, chatId: string, topicId: number, update: Partial, ): T { @@ -82,29 +68,46 @@ export function updateTopic( const topic = selectTopic(global, chatId, topicId); const oldTopics = selectTopics(global, chatId); + const safeUpdate = update.isMin ? pick(update, SAFE_MIN_PROPERTIES) : update; + const updatedTopic = { ...topic, - ...update, + ...safeUpdate, } as ApiTopic; if (!updatedTopic.id) return global; - global = updateTopicsStore(global, chatId, { + global = updateTopicsInfo(global, chatId, { topicsById: { ...oldTopics, [topicId]: updatedTopic, }, }); - global = updateThread(global, chatId, updatedTopic.id, { + global = updateThreadLocalState(global, chatId, updatedTopic.id, { firstMessageId: updatedTopic.id, }); - global = updateThreadInfo(global, chatId, updatedTopic.id, { - lastMessageId: updatedTopic.lastMessageId, - threadId: updatedTopic.id, - chatId, - }); + return global; +} + +export function updateTopicWithState( + global: T, chatId: string, topicWithState: ApiTopicWithState, +): T { + const topicId = topicWithState.topic.id; + + global = updateTopic(global, chatId, topicId, topicWithState.topic); + if (!topicWithState.topic.isMin) { + global = updateThreadInfo(global, { + isCommentsInfo: false, + chatId, + threadId: topicId, + lastMessageId: topicWithState.lastMessageId, + }); + } + if (topicWithState.readState) { + global = updateThreadReadState(global, chatId, topicId, topicWithState.readState); + } return global; } @@ -115,25 +118,17 @@ export function deleteTopic( const topics = selectTopics(global, chatId); if (!topics) return global; - global = updateTopicsStore(global, chatId, { + global = updateTopicsInfo(global, chatId, { topicsById: omit(topics, [topicId]), }); return global; } -export function updateTopicLastMessageId( - global: T, chatId: string, threadId: number, lastMessageId: number, -) { - return updateTopic(global, chatId, threadId, { - lastMessageId, - }); -} - export function replacePinnedTopicIds( global: T, chatId: string, pinnedTopicIds: number[], ) { - return updateTopicsStore(global, chatId, { + return updateTopicsInfo(global, chatId, { orderedPinnedTopicIds: pinnedTopicIds, }); } diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index a3b1846a6..1308a9184 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -12,9 +12,7 @@ import type { import type { ChatTranslatedMessages, MessageListType, - TabThread, TextSummary, - Thread, ThreadId, } from '../../types'; import type { IAllowedAttachmentOptions } from '../helpers'; @@ -24,7 +22,8 @@ import type { import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types'; import { - ANONYMOUS_USER_ID, GENERAL_TOPIC_ID, SERVICE_NOTIFICATIONS_USER_ID, + GENERAL_TOPIC_ID, + SERVICE_NOTIFICATIONS_USER_ID, WEB_APP_PLATFORM, } from '../../config'; import { IS_TRANSLATION_SUPPORTED } from '../../util/browser/windowEnvironment'; @@ -86,7 +85,17 @@ import { selectPeer, selectPeerPaidMessagesStars } from './peers'; import { selectPeerStory } from './stories'; import { selectCustomEmoji, selectIsStickerFavorite } from './symbols'; import { selectTabState } from './tabs'; -import { selectTopic } from './topics'; +import { + selectEditingId, + selectEditingScheduledId, + selectTabThreadParam, + selectThreadByMessage, + selectThreadInfo, + selectThreadLocalState, + selectThreadLocalStateParam, + selectThreadReadState, +} from './threads'; +import { selectTopic, selectTopicFromMessage } from './topics'; import { selectBot, selectUser, selectUserStatus, } from './users'; @@ -121,45 +130,8 @@ export function selectChatScheduledMessages(global: T, ch return global.scheduledMessages.byChatId[chatId]?.byId; } -export function selectTabThreadParam( - global: T, - chatId: string, - threadId: ThreadId, - key: K, - ...[tabId = getCurrentTabId()]: TabArgs -) { - return selectTabState(global, tabId).tabThreads[chatId]?.[threadId]?.[key]; -} - -export function selectThreadParam( - global: T, - chatId: string, - threadId: ThreadId, - key: K, -) { - return selectThread(global, chatId, threadId)?.[key]; -} - -export function selectThread( - global: T, - chatId: string, - threadId: ThreadId, -) { - const messageInfo = global.messages.byChatId[chatId]; - if (!messageInfo) { - return undefined; - } - - const thread = messageInfo.threadsById[threadId]; - if (!thread) { - return undefined; - } - - return thread; -} - export function selectListedIds(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'listedIds'); + return selectThreadLocalStateParam(global, chatId, threadId, 'listedIds'); } export function selectOutlyingListByMessageId( @@ -178,7 +150,7 @@ export function selectOutlyingListByMessageId( export function selectOutlyingLists( global: T, chatId: string, threadId: ThreadId, ) { - return selectThreadParam(global, chatId, threadId, 'outlyingLists'); + return selectThreadLocalStateParam(global, chatId, threadId, 'outlyingLists'); } export function selectCurrentMessageIds( @@ -205,79 +177,15 @@ export function selectViewportIds( } export function selectPinnedIds(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'pinnedIds'); + return selectThreadLocalStateParam(global, chatId, threadId, 'pinnedIds'); } export function selectScheduledIds(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'scheduledIds'); -} - -export function selectScrollOffset( - global: T, chatId: string, threadId: ThreadId, - ...[tabId = getCurrentTabId()]: TabArgs -) { - return selectTabThreadParam(global, chatId, threadId, 'scrollOffset', tabId); -} - -export function selectLastScrollOffset(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'lastScrollOffset'); -} - -export function selectEditingId(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'editingId'); -} - -export function selectEditingDraft(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'editingDraft'); -} - -export function selectEditingScheduledId(global: T, chatId: string) { - return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledId'); -} - -export function selectEditingScheduledDraft(global: T, chatId: string) { - return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledDraft'); -} - -export function selectDraft(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'draft'); -} - -export function selectNoWebPage(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'noWebPage'); -} - -export function selectThreadInfo(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'threadInfo'); + return selectThreadLocalStateParam(global, chatId, threadId, 'scheduledIds'); } export function selectFirstMessageId(global: T, chatId: string, threadId: ThreadId) { - return selectThreadParam(global, chatId, threadId, 'firstMessageId'); -} - -export function selectReplyStack( - global: T, chatId: string, threadId: ThreadId, - ...[tabId = getCurrentTabId()]: TabArgs -) { - return selectTabThreadParam(global, chatId, threadId, 'replyStack', tabId); -} - -export function selectThreadMessagesCount(global: GlobalState, chatId: string, threadId: ThreadId) { - const chat = selectChat(global, chatId); - const threadInfo = selectThreadInfo(global, chatId, threadId); - if (!chat || !threadInfo || threadInfo.messagesCount === undefined) return undefined; - // In forum topics first message is ignored, but not in General - if (chat.isForum && threadId !== GENERAL_TOPIC_ID) return threadInfo.messagesCount - 1; - return threadInfo.messagesCount; -} - -export function selectThreadByMessage(global: T, message: ApiMessage) { - const threadId = selectThreadIdFromMessage(global, message); - if (!threadId || threadId === MAIN_THREAD_ID) { - return undefined; - } - - return global.messages.byChatId[message.chatId].threadsById[threadId]; + return selectThreadLocalStateParam(global, chatId, threadId, 'firstMessageId'); } export function selectIsMessageInCurrentMessageList( @@ -391,18 +299,31 @@ export function selectIsMessageFocused( return focusedId ? focusedId === message.id || focusedId === message.previousLocalId : false; } -export function selectIsMessageUnread(global: T, message: ApiMessage) { - const { lastReadOutboxMessageId } = selectChat(global, message.chatId) || {}; - return isMessageLocal(message) || !lastReadOutboxMessageId || lastReadOutboxMessageId < message.id; +export function selectIsMessageUnread( + global: T, chatId: string, threadId: ThreadId, messageId: number, messageListType: MessageListType, +) { + const { lastReadOutboxMessageId } = selectThreadReadState(global, chatId, threadId) || {}; + const message = selectChatMessage(global, chatId, messageId); + if (!message) { + return false; + } + + return messageListType === 'scheduled' || isMessageLocal(message) + || !lastReadOutboxMessageId || lastReadOutboxMessageId < message.id; } export function selectOutgoingStatus( - global: T, message: ApiMessage, isScheduledList = false, + global: T, chatId: string, threadId: ThreadId, messageId: number, messageListType: MessageListType, ): ApiMessageOutgoingStatus { - if (!selectIsMessageUnread(global, message) && !isScheduledList) { + if (!selectIsMessageUnread(global, chatId, threadId, messageId, messageListType)) { return 'read'; } + const message = selectChatMessage(global, chatId, messageId); + if (!message) { + return 'failed'; // Should never happen + } + return getSendingState(message); } @@ -514,24 +435,15 @@ export function selectFullWebPageFromMessage(global: T, m return selectFullWebPage(global, message.content.webPage.id); } -export function selectTopicFromMessage(global: T, message: ApiMessage) { - const { chatId } = message; - const chat = selectChat(global, chatId); - if (!chat?.isForum) return undefined; - - const threadId = selectThreadIdFromMessage(global, message); - return selectTopic(global, chatId, threadId); -} - const MAX_MESSAGES_TO_DELETE_OWNER_TOPIC = 10; export function selectCanDeleteOwnerTopic(global: T, chatId: string, topicId: number) { const topic = selectTopic(global, chatId, topicId); if (topic && !topic.isOwner) return false; - const thread = selectThread(global, chatId, topicId); - if (!thread) return false; + const localThreadState = selectThreadLocalState(global, chatId, topicId); + if (!localThreadState) return false; - const { listedIds } = thread; + const { listedIds } = localThreadState; if (!listedIds // Plus one for root message || listedIds.length + 1 >= MAX_MESSAGES_TO_DELETE_OWNER_TOPIC) { @@ -559,59 +471,6 @@ export function selectCanDeleteTopic(global: T, chatId: s && selectCanDeleteOwnerTopic(global, chat.id, topicId)); } -export function selectSavedDialogIdFromMessage( - global: T, message: ApiMessage, -): string | undefined { - const { - chatId, senderId, forwardInfo, savedPeerId, - } = message; - - if (savedPeerId) return savedPeerId; - - if (chatId !== global.currentUserId) { - return undefined; - } - - if (forwardInfo?.savedFromPeerId) { - return forwardInfo.savedFromPeerId; - } - - if (forwardInfo?.fromId) { - return forwardInfo.fromId; - } - - if (forwardInfo?.hiddenUserName) { - return ANONYMOUS_USER_ID; - } - - return senderId; -} - -export function selectThreadIdFromMessage(global: T, message: ApiMessage): ThreadId { - const savedDialogId = selectSavedDialogIdFromMessage(global, message); - if (savedDialogId) { - return savedDialogId; - } - - const chat = selectChat(global, message.chatId); - const { content } = message; - const { replyToMsgId, replyToTopId, isForumTopic } = getMessageReplyInfo(message) || {}; - if (content.action?.type === 'topicCreate') { - return message.id; - } - - if (!chat?.isForum) { - if (chat && isChatBasicGroup(chat)) return MAIN_THREAD_ID; - - if (chat && isChatSuperGroup(chat)) { - return replyToTopId || replyToMsgId || MAIN_THREAD_ID; - } - return MAIN_THREAD_ID; - } - if (!isForumTopic) return GENERAL_TOPIC_ID; - return replyToTopId || replyToMsgId || GENERAL_TOPIC_ID; -} - export function selectCanReplyToMessage(global: T, message: ApiMessage, threadId: ThreadId) { const chat = selectChat(global, message.chatId); const isRestricted = selectIsChatRestricted(global, message.chatId); @@ -917,37 +776,23 @@ export function selectUploadProgress(global: T, message: } export function selectRealLastReadId(global: T, chatId: string, threadId: ThreadId) { - if (threadId === MAIN_THREAD_ID) { - const chat = selectChat(global, chatId); - if (!chat) { - return undefined; - } - - // `lastReadInboxMessageId` is empty for new chats - if (!chat.lastReadInboxMessageId) { - return undefined; - } - - const lastMessageId = selectChatLastMessageId(global, chatId); - - if (!lastMessageId || chat.unreadCount) { - return chat.lastReadInboxMessageId; - } - - return lastMessageId; - } else { - const threadInfo = selectThreadInfo(global, chatId, threadId); - if (!threadInfo) { - return undefined; - } - - if (!threadInfo.lastReadInboxMessageId) { - return Number(threadInfo.threadId); - } - - // Some previously read messages may be deleted - return Math.min(threadInfo.lastReadInboxMessageId, threadInfo.lastMessageId || Infinity); + const readState = selectThreadReadState(global, chatId, threadId); + if (!readState) { + return undefined; } + + // `lastReadInboxMessageId` is empty for new chats + if (!readState.lastReadInboxMessageId) { + return undefined; + } + + const lastMessageId = selectChatLastMessageId(global, chatId); + + if (!lastMessageId || readState.unreadCount) { + return readState.lastReadInboxMessageId; + } + + return lastMessageId; } export function selectFirstUnreadId( @@ -961,8 +806,9 @@ export function selectFirstUnreadId( } } else { const threadInfo = selectThreadInfo(global, chatId, threadId); - if (!threadInfo - || (threadInfo.lastMessageId !== undefined && threadInfo.lastMessageId === threadInfo.lastReadInboxMessageId)) { + const readState = selectThreadReadState(global, chatId, threadId); + if (!threadInfo || !readState + || (threadInfo.lastMessageId !== undefined && threadInfo.lastMessageId === readState.lastReadInboxMessageId)) { return undefined; } } diff --git a/src/global/selectors/payments.ts b/src/global/selectors/payments.ts index 26e795751..892a1a6f7 100644 --- a/src/global/selectors/payments.ts +++ b/src/global/selectors/payments.ts @@ -1,7 +1,7 @@ import type { GlobalState, TabArgs } from '../types'; import { DEFAULT_GIFT_PROFILE_FILTER_OPTIONS } from '../../config'; -import arePropsShallowEqual from '../../util/arePropsShallowEqual'; +import { areRecordsShallowEqual } from '../../util/areShallowEqual'; import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getHasAdminRight, isChatAdmin, isChatChannel, @@ -96,7 +96,7 @@ export function selectIsGiftProfileFilterDefault( global: T, ...[tabId = getCurrentTabId()]: TabArgs ) { - return arePropsShallowEqual(selectTabState(global, tabId).savedGifts.filter, DEFAULT_GIFT_PROFILE_FILTER_OPTIONS); + return areRecordsShallowEqual(selectTabState(global, tabId).savedGifts.filter, DEFAULT_GIFT_PROFILE_FILTER_OPTIONS); } export function selectActiveGiftsCollectionId( diff --git a/src/global/selectors/settings.ts b/src/global/selectors/settings.ts index 1c80569ff..76cdaee05 100644 --- a/src/global/selectors/settings.ts +++ b/src/global/selectors/settings.ts @@ -44,3 +44,7 @@ export function selectShouldHideReadMarks(global: T) { export function selectSettingsKeys(global: T) { return global.settings.byKey; } + +export function selectTimezones(global: T) { + return global.timezones?.byId; +} diff --git a/src/global/selectors/threads.ts b/src/global/selectors/threads.ts new file mode 100644 index 000000000..0dd806199 --- /dev/null +++ b/src/global/selectors/threads.ts @@ -0,0 +1,176 @@ +import type { TabThread, ThreadId, ThreadLocalState } from '../../types'; +import type { GlobalState, TabArgs } from '../types'; +import { type ApiMessage, MAIN_THREAD_ID } from '../../api/types'; + +import { ANONYMOUS_USER_ID, GENERAL_TOPIC_ID } from '../../config'; +import { getCurrentTabId } from '../../util/establishMultitabRole'; +import { isChatBasicGroup, isChatSuperGroup } from '../helpers'; +import { getMessageReplyInfo } from '../helpers/replies'; +import { selectChat } from './chats'; +import { selectTabState } from './tabs'; + +export function selectTabThreadParam( + global: T, + chatId: string, + threadId: ThreadId, + key: K, + ...[tabId = getCurrentTabId()]: TabArgs +) { + return selectTabState(global, tabId).tabThreads[chatId]?.[threadId]?.[key]; +} + +export function selectThreadInfo(global: T, chatId: string, threadId: ThreadId) { + return selectThread(global, chatId, threadId)?.threadInfo; +} + +export function selectThreadReadState(global: T, chatId: string, threadId: ThreadId) { + return selectThread(global, chatId, threadId)?.readState; +} + +export function selectThreadLocalStateParam( + global: T, + chatId: string, + threadId: ThreadId, + key: K, +) { + return selectThreadLocalState(global, chatId, threadId)?.[key]; +} + +export function selectThread( + global: T, + chatId: string, + threadId: ThreadId, +) { + const messageInfo = global.messages.byChatId[chatId]; + if (!messageInfo) { + return undefined; + } + + const thread = messageInfo.threadsById[threadId]; + if (!thread) { + return undefined; + } + + return thread; +} + +export function selectThreadLocalState( + global: T, + chatId: string, + threadId: ThreadId, +) { + return selectThread(global, chatId, threadId)?.localState; +} + +export function selectThreadMessagesCount(global: GlobalState, chatId: string, threadId: ThreadId) { + const chat = selectChat(global, chatId); + const threadInfo = selectThreadInfo(global, chatId, threadId); + if (!chat || !threadInfo || threadInfo.messagesCount === undefined) return undefined; + // In forum topics first message is ignored, but not in General + if (chat.isForum && threadId !== GENERAL_TOPIC_ID) return Math.max(threadInfo.messagesCount - 1, 0); + return threadInfo.messagesCount; +} + +export function selectThreadByMessage(global: T, message: ApiMessage) { + const threadId = selectThreadIdFromMessage(global, message); + if (!threadId || threadId === MAIN_THREAD_ID) { + return undefined; + } + + return selectThread(global, message.chatId, threadId); +} + +export function selectScrollOffset( + global: T, chatId: string, threadId: ThreadId, + ...[tabId = getCurrentTabId()]: TabArgs +) { + return selectTabThreadParam(global, chatId, threadId, 'scrollOffset', tabId); +} + +export function selectLastScrollOffset(global: T, chatId: string, threadId: ThreadId) { + return selectThreadLocalStateParam(global, chatId, threadId, 'lastScrollOffset'); +} + +export function selectEditingId(global: T, chatId: string, threadId: ThreadId) { + return selectThreadLocalStateParam(global, chatId, threadId, 'editingId'); +} + +export function selectEditingDraft(global: T, chatId: string, threadId: ThreadId) { + return selectThreadLocalStateParam(global, chatId, threadId, 'editingDraft'); +} + +export function selectEditingScheduledId(global: T, chatId: string) { + return selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledId'); +} + +export function selectEditingScheduledDraft(global: T, chatId: string) { + return selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledDraft'); +} + +export function selectDraft(global: T, chatId: string, threadId: ThreadId) { + return selectThreadLocalStateParam(global, chatId, threadId, 'draft'); +} + +export function selectNoWebPage(global: T, chatId: string, threadId: ThreadId) { + return selectThreadLocalStateParam(global, chatId, threadId, 'noWebPage'); +} + +export function selectReplyStack( + global: T, chatId: string, threadId: ThreadId, + ...[tabId = getCurrentTabId()]: TabArgs +) { + return selectTabThreadParam(global, chatId, threadId, 'replyStack', tabId); +} + +export function selectSavedDialogIdFromMessage( + global: T, message: ApiMessage, +): string | undefined { + const { + chatId, senderId, forwardInfo, savedPeerId, + } = message; + + if (savedPeerId) return savedPeerId; + + if (chatId !== global.currentUserId) { + return undefined; + } + + if (forwardInfo?.savedFromPeerId) { + return forwardInfo.savedFromPeerId; + } + + if (forwardInfo?.fromId) { + return forwardInfo.fromId; + } + + if (forwardInfo?.hiddenUserName) { + return ANONYMOUS_USER_ID; + } + + return senderId; +} + +export function selectThreadIdFromMessage(global: T, message: ApiMessage): ThreadId { + const savedDialogId = selectSavedDialogIdFromMessage(global, message); + if (savedDialogId) { + return savedDialogId; + } + + const chat = selectChat(global, message.chatId); + const { content } = message; + const { replyToMsgId, replyToTopId, isForumTopic } = getMessageReplyInfo(message) || {}; + if (content.action?.type === 'topicCreate') { + return message.id; + } + + if (!chat?.isForum) { + if (chat && isChatBasicGroup(chat)) return MAIN_THREAD_ID; + + if (chat && isChatSuperGroup(chat)) { + return replyToTopId || replyToMsgId || MAIN_THREAD_ID; + } + return MAIN_THREAD_ID; + } + if (!isForumTopic) return GENERAL_TOPIC_ID; + return replyToTopId || replyToMsgId || GENERAL_TOPIC_ID; +} diff --git a/src/global/selectors/topics.ts b/src/global/selectors/topics.ts index 28ff3979e..70b8ef388 100644 --- a/src/global/selectors/topics.ts +++ b/src/global/selectors/topics.ts @@ -1,6 +1,10 @@ +import type { ApiMessage } from '../../api/types'; import type { ThreadId, TopicsInfo } from '../../types'; import type { GlobalState } from '../types'; +import { selectChat } from './chats'; +import { selectThreadIdFromMessage } from './threads'; + export function selectTopicsInfo(global: T, chatId: string): TopicsInfo | undefined { return global.chats.topicsInfoById[chatId]; } @@ -12,3 +16,12 @@ export function selectTopics(global: T, chatId: string) { export function selectTopic(global: T, chatId: string, threadId: ThreadId) { return selectTopicsInfo(global, chatId)?.topicsById?.[threadId]; } + +export function selectTopicFromMessage(global: T, message: ApiMessage) { + const { chatId } = message; + const chat = selectChat(global, chatId); + if (!chat?.isForum) return undefined; + + const threadId = selectThreadIdFromMessage(global, message); + return selectTopic(global, chatId, threadId); +} diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index ccc68f81c..1806f2b24 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -529,17 +529,13 @@ export interface ActionPayloads { maxId: number; } & WithTabId; markMessagesRead: { + chatId: string; messageIds: number[]; - shouldFetchUnreadReactions?: boolean; - } & WithTabId; + }; loadMessage: { chatId: string; messageId: number; replyOriginForId?: number; - threadUpdate?: { - lastMessageId: number; - isDeleting?: boolean; - }; }; loadMessagesById: { chatId: string; @@ -1410,12 +1406,14 @@ export interface ActionPayloads { threadId: ThreadId; type: MessageListType; }; - fetchUnreadMentions: { + loadUnreadMentions: { chatId: string; + threadId?: ThreadId; offsetId?: number; }; - fetchUnreadReactions: { + loadUnreadReactions: { chatId: string; + threadId?: ThreadId; offsetId?: number; }; scheduleForViewsIncrement: { @@ -1441,10 +1439,17 @@ export interface ActionPayloads { quickReplyId: number; }; animateUnreadReaction: { + chatId: string; messageIds: number[]; } & WithTabId; - focusNextReaction: WithTabId | undefined; - focusNextMention: WithTabId | undefined; + focusNextReaction: { + chatId: string; + threadId?: ThreadId; + } & WithTabId; + focusNextMention: { + chatId: string; + threadId?: ThreadId; + } & WithTabId; readAllReactions: { chatId: string; threadId?: ThreadId; @@ -1456,7 +1461,7 @@ export interface ActionPayloads { markMentionsRead: { chatId: string; messageIds: number[]; - } & WithTabId; + }; copyMessageLink: { chatId: string; messageId: number; diff --git a/src/hooks/data/useSelector.ts b/src/hooks/data/useSelector.ts index 0c05173a8..5dc2eb863 100644 --- a/src/hooks/data/useSelector.ts +++ b/src/hooks/data/useSelector.ts @@ -1,11 +1,119 @@ +import { useRef, useSignal } from '../../lib/teact/teact'; +import { addCallback } from '../../lib/teact/teactn'; +import { getGlobal } from '../../global'; + import type { GlobalState } from '../../global/types'; +import areShallowEqual from '../../util/areShallowEqual'; +import { createSignal, type Signal, type SignalSetter } from '../../util/signals'; import useDerivedState from '../useDerivedState'; -import useSelectorSignal from './useSelectorSignal'; +import useSyncEffect from '../useSyncEffect'; type Selector = (global: GlobalState) => T; +type EqualityFn = (oldValue: T, newValue: T) => boolean; -export default function useSelector(selector: Selector) { - const selectorSignal = useSelectorSignal(selector); - return useDerivedState(selectorSignal); +interface State { + clientsCount: number; + getter: Signal; + setter: SignalSetter; } + +const bySelector = new Map, State>(); + +let currentGlobal = getGlobal(); +addCallback((global: GlobalState) => { + currentGlobal = global; + + for (const [selector, { setter }] of bySelector) { + setter(selector(global)); + } +}); + +/** + * @param selector - A stable or memoized selector function. + */ +export function useSelectorSignal(selector: Selector): Signal { + let state = bySelector.get(selector) as State | undefined; + if (!state) { + const [getter, setter] = createSignal(selector(currentGlobal)); + state = { clientsCount: 0, getter, setter }; + bySelector.set(selector, state as State); + } + + useSyncEffect(() => { + const currentState = bySelector.get(selector); + if (!currentState) { + return undefined; + } + + currentState.clientsCount++; + + // Refresh if selector changed + const currentValue = selector(currentGlobal); + if (currentValue !== currentState.getter()) { + currentState.setter(currentValue); + } + + return () => { + currentState.clientsCount--; + + if (!currentState.clientsCount) { + bySelector.delete(selector); + } + }; + }, [selector]); + + return state.getter; +} + +/** + * @param selector - A stable or memoized selector function. + */ +export function useSelector(selector: Selector) { + const selectorSignal = useSelectorSignal(selector); + return useDerivedState(selectorSignal, [selectorSignal, selector]); +} + +export function useSelectorSignalWithEquality( + selector: Selector, + equalityFn: EqualityFn, +) { + const baseSignal = useSelectorSignal(selector); + // Initialize with current value + const [signal, setSignal] = useSignal(baseSignal()); + const lastValueRef = useRef(signal()); + + useSyncEffect(() => { + const checkForUpdate = () => { + const newValue = baseSignal(); + if (!equalityFn(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setSignal(newValue); + } + }; + + checkForUpdate(); + + return baseSignal.subscribe(checkForUpdate); + }, [baseSignal, equalityFn, setSignal]); + + return signal; +} + +export function useSelectorWithEquality( + selector: Selector, + equalityFn: EqualityFn, +) { + const signal = useSelectorSignalWithEquality(selector, equalityFn); + return useDerivedState(signal); +} + +export function useShallowSelectorSignal(selector: Selector) { + return useSelectorSignalWithEquality(selector, areShallowEqual); +} + +export function useShallowSelector(selector: Selector) { + return useSelectorWithEquality(selector, areShallowEqual); +} + +export default useSelector; diff --git a/src/hooks/data/useSelectorSignal.ts b/src/hooks/data/useSelectorSignal.ts deleted file mode 100644 index 9bf9fccd6..000000000 --- a/src/hooks/data/useSelectorSignal.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { addCallback } from '../../lib/teact/teactn'; -import { getGlobal } from '../../global'; - -import type { GlobalState } from '../../global/types'; -import type { Signal, SignalSetter } from '../../util/signals'; - -import { createSignal } from '../../util/signals'; -import useSyncEffect from '../useSyncEffect'; - -/* - This hook is a more performant variation of the standard React `useSelector` hook. It allows to: - a) Avoid multiple subscriptions to global updates by leveraging a single selector reference. - b) Return a signal instead of forcing a component update right away. - */ - -type Selector = (global: GlobalState) => T; - -interface State { - clientsCount: number; - getter: Signal; - setter: SignalSetter; -} - -const bySelector = new Map, State>(); - -addCallback((global: GlobalState) => { - for (const [selector, { setter }] of bySelector) { - setter(selector(global)); - } -}); - -function useSelectorSignal(selector: Selector): Signal { - let state = bySelector.get(selector) as State | undefined; - if (!state) { - const [getter, setter] = createSignal(selector(getGlobal())); - state = { clientsCount: 0, getter, setter }; - bySelector.set(selector, state as State); - } - - useSyncEffect(() => { - const state2 = bySelector.get(selector)!; - - state2.clientsCount++; - - return () => { - state2.clientsCount--; - - if (!state2.clientsCount) { - bySelector.delete(selector); - } - }; - }, [selector]); - - return state.getter; -} - -export default useSelectorSignal; diff --git a/src/hooks/useChatContextActions.ts b/src/hooks/useChatContextActions.ts index 01dbd49dc..7dff503dc 100644 --- a/src/hooks/useChatContextActions.ts +++ b/src/hooks/useChatContextActions.ts @@ -1,15 +1,18 @@ -import { useMemo } from '../lib/teact/teact'; +import { useCallback, useMemo } from '../lib/teact/teact'; import { getActions } from '../global'; -import type { ApiChat, ApiTopic, ApiUser } from '../api/types'; import type { MenuItemContextAction } from '../components/ui/ListItem'; +import type { GlobalState } from '../global/types'; +import { type ApiChat, type ApiUser, MAIN_THREAD_ID } from '../api/types'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../config'; import { getCanDeleteChat, isChatArchived, isChatChannel, isChatGroup } from '../global/helpers'; +import { selectThreadReadState } from '../global/selectors/threads'; import { IS_TAURI } from '../util/browser/globalEnvironment'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../util/browser/windowEnvironment'; import { isUserId } from '../util/entities/ids'; -import { compact } from '../util/iteratees'; +import { buildCollectionByCallback, compact } from '../util/iteratees'; +import useSelector, { useShallowSelector } from './data/useSelector'; import useLang from './useLang'; const useChatContextActions = ({ @@ -22,7 +25,7 @@ const useChatContextActions = ({ isSavedDialog, currentUserId, isPreview, - topics, + topicIds, handleDelete, handleMute, handleUnmute, @@ -38,18 +41,42 @@ const useChatContextActions = ({ isSavedDialog?: boolean; currentUserId?: string; isPreview?: boolean; - topics?: Record; + topicIds?: number[]; handleDelete?: NoneToVoidFunction; handleMute?: NoneToVoidFunction; handleUnmute?: NoneToVoidFunction; handleChatFolderChange: NoneToVoidFunction; handleReport?: NoneToVoidFunction; }, isInSearch = false) => { + const { + toggleChatPinned, + toggleSavedDialogPinned, + toggleChatArchived, + markChatMessagesRead, + markChatUnread, + openChatInNewTab, + openQuickPreview, + } = getActions(); + const lang = useLang(); const { isSelf } = user || {}; const isServiceNotifications = user?.id === SERVICE_NOTIFICATIONS_USER_ID; + const topicsReadStateSelector = useCallback((global: GlobalState) => { + if (!chat?.id) return undefined; + return buildCollectionByCallback(topicIds || [], (tId) => ( + [tId, selectThreadReadState(global, chat?.id, tId)] + )); + }, [chat?.id, topicIds]); + const topicsReadStates = useShallowSelector(topicsReadStateSelector); + + const chatReadStateSelector = useCallback((global: GlobalState) => { + if (!chat?.id) return undefined; + return selectThreadReadState(global, chat.id, MAIN_THREAD_ID); + }, [chat?.id]); + const chatReadState = useSelector(chatReadStateSelector); + const deleteTitle = useMemo(() => { if (!chat) return undefined; @@ -72,21 +99,11 @@ const useChatContextActions = ({ return lang('GroupLeaveGroup'); }, [chat, isSavedDialog, lang]); - return useMemo(() => { + const preparedActions = useMemo(() => { if (!chat || isPreview) { return undefined; } - const { - toggleChatPinned, - toggleSavedDialogPinned, - toggleChatArchived, - markChatMessagesRead, - markChatUnread, - openChatInNewTab, - openQuickPreview, - } = getActions(); - const actionOpenInNewTab = IS_OPEN_IN_NEW_TAB_SUPPORTED && { title: IS_TAURI ? lang('ChatListOpenInNewWindow') : lang('ChatListOpenInNewTab'), icon: 'open-in-new-tab', @@ -97,7 +114,7 @@ const useChatContextActions = ({ openChatInNewTab({ chatId: chat.id }); } }, - }; + } satisfies MenuItemContextAction; const actionQuickPreview = !isSavedDialog && !chat.isForum && { title: lang('QuickPreview'), @@ -107,7 +124,7 @@ const useChatContextActions = ({ id: chat.id, }); }, - }; + } satisfies MenuItemContextAction; const togglePinned = () => { if (isSavedDialog) { @@ -117,7 +134,7 @@ const useChatContextActions = ({ } }; - const actionPin = isPinned + const actionPin: MenuItemContextAction = isPinned ? { title: lang('ChatListUnpinFromTop'), icon: 'unpin', @@ -129,12 +146,12 @@ const useChatContextActions = ({ handler: togglePinned, }; - const actionDelete = { + const actionDelete = deleteTitle ? { title: deleteTitle, icon: 'delete', destructive: true, handler: handleDelete, - }; + } satisfies MenuItemContextAction : undefined; if (isSavedDialog) { return compact([actionOpenInNewTab, actionQuickPreview, actionPin, actionDelete]) as MenuItemContextAction[]; @@ -144,9 +161,9 @@ const useChatContextActions = ({ title: lang('ChatListContextAddToFolder'), icon: 'folder', handler: handleChatFolderChange, - } : undefined; + } satisfies MenuItemContextAction : undefined; - const actionMute = isMuted + const actionMute: MenuItemContextAction = isMuted ? { title: lang('ChatsUnmute'), icon: 'unmute', @@ -164,46 +181,51 @@ const useChatContextActions = ({ ]) as MenuItemContextAction[]; } - const actionMaskAsRead = ( - chat.unreadCount || chat.hasUnreadMark || Object.values(topics || {}).some(({ unreadCount }) => unreadCount) - ) + const actionMarkAsRead = ( + chatReadState?.unreadCount || chatReadState?.hasUnreadMark + || Object.values(topicsReadStates || {}).some((readState) => readState?.unreadCount) + ) ? { + title: lang('ChatListContextMarkAsRead'), + icon: 'readchats', + handler: () => markChatMessagesRead({ id: chat.id }), + } satisfies MenuItemContextAction + : undefined; + const actionMarkAsUnread = !(chatReadState?.unreadCount || chatReadState?.hasUnreadMark) && !chat.isForum ? { - title: lang('ChatListContextMaskAsRead'), - icon: 'readchats', - handler: () => markChatMessagesRead({ id: chat.id }), - } : undefined; - const actionMarkAsUnread = !(chat.unreadCount || chat.hasUnreadMark) && !chat.isForum - ? { title: lang('ChatListContextMaskAsUnread'), icon: 'unread', handler: () => markChatUnread({ id: chat.id }) } + title: lang('ChatListContextMarkAsUnread'), icon: 'unread', handler: () => markChatUnread({ id: chat.id }), + } satisfies MenuItemContextAction : undefined; - const actionArchive = isChatArchived(chat) + const actionArchive: MenuItemContextAction = isChatArchived(chat) ? { title: lang('Unarchive'), icon: 'unarchive', handler: () => toggleChatArchived({ id: chat.id }) } : { title: lang('Archive'), icon: 'archive', handler: () => toggleChatArchived({ id: chat.id }) }; const canReport = handleReport && !user && (isChatChannel(chat) || isChatGroup(chat)); const actionReport = canReport - ? { title: lang('ReportPeerReport'), icon: 'flag', handler: handleReport } + ? { title: lang('ReportPeerReport'), icon: 'flag', handler: handleReport } satisfies MenuItemContextAction : undefined; const isInFolder = folderId !== undefined; - return compact([ + return compact([ actionOpenInNewTab, actionQuickPreview, actionAddToFolder, - actionMaskAsRead, + actionMarkAsRead, actionMarkAsUnread, actionPin, !isSelf && actionMute, !isSelf && !isServiceNotifications && !isInFolder && actionArchive, actionReport, actionDelete, - ]) as MenuItemContextAction[]; + ]); }, [ - chat, user, canChangeFolder, lang, handleChatFolderChange, isPinned, isInSearch, isMuted, currentUserId, - handleDelete, handleMute, handleReport, folderId, isSelf, isServiceNotifications, isSavedDialog, deleteTitle, - isPreview, topics, handleUnmute, + chat, isPreview, lang, isSavedDialog, isPinned, deleteTitle, handleDelete, canChangeFolder, + handleChatFolderChange, isMuted, handleUnmute, handleMute, isInSearch, chatReadState, topicsReadStates, + handleReport, user, folderId, isSelf, isServiceNotifications, currentUserId, ]); + + return preparedActions; }; export default useChatContextActions; diff --git a/src/hooks/useSignalEffect.ts b/src/hooks/useSignalEffect.ts index 2018fbfd3..40a1d3102 100644 --- a/src/hooks/useSignalEffect.ts +++ b/src/hooks/useSignalEffect.ts @@ -1,21 +1,42 @@ import { useRef, useUnmountCleanup } from '../lib/teact/teact'; +import { requestMeasure } from '../lib/fasterdom/fasterdom'; import { cleanupEffect, isSignal } from '../util/signals'; export function useSignalEffect(effect: NoneToVoidFunction, dependencies: readonly any[]) { - // The is extracted from `useEffectOnce` to run before all effects - const isFirstRun = useRef(true); - if (isFirstRun.current) { - isFirstRun.current = false; + // This runs before all effects + const prevDepsRef = useRef(); + const subscribedEffectRef = useRef(); - dependencies?.forEach((dependency) => { + const prevDeps = prevDepsRef.current; + const hasChanged = !prevDeps + || dependencies.length !== prevDeps.length + || dependencies.some((dep, i) => dep !== prevDeps[i]); + + if (hasChanged) { + if (subscribedEffectRef.current) { + cleanupEffect(subscribedEffectRef.current); + } + + subscribedEffectRef.current = effect; + prevDepsRef.current = dependencies; + + dependencies.forEach((dependency) => { if (isSignal(dependency)) { dependency.subscribe(effect); } }); + + const currentEffect = effect; + requestMeasure(() => { + if (subscribedEffectRef.current !== currentEffect) return; + currentEffect(); + }); } useUnmountCleanup(() => { - cleanupEffect(effect); + if (subscribedEffectRef.current) { + cleanupEffect(subscribedEffectRef.current); + } }); } diff --git a/src/lib/teact/teact.ts b/src/lib/teact/teact.ts index 30ef176e6..8d1f9a663 100644 --- a/src/lib/teact/teact.ts +++ b/src/lib/teact/teact.ts @@ -2,7 +2,7 @@ import type { ReactElement } from 'react'; import { DEBUG, DEBUG_MORE } from '../../config'; -import { logUnequalProps } from '../../util/arePropsShallowEqual'; +import { logUnequalProps } from '../../util/areShallowEqual'; import { incrementOverlayCounter } from '../../util/debugOverlay'; import { orderBy } from '../../util/iteratees'; import safeExec from '../../util/safeExec'; diff --git a/src/lib/teact/teactn.tsx b/src/lib/teact/teactn.tsx index 61e6d9027..6992176bb 100644 --- a/src/lib/teact/teactn.tsx +++ b/src/lib/teact/teactn.tsx @@ -1,7 +1,7 @@ import type { FC, FC_withDebug, Props } from './teact'; import { DEBUG, DEBUG_MORE } from '../../config'; -import arePropsShallowEqual, { logUnequalProps } from '../../util/arePropsShallowEqual'; +import { areRecordsShallowEqual, logUnequalProps } from '../../util/areShallowEqual'; import Deferred from '../../util/Deferred'; import { handleError } from '../../util/handleError'; import { orderBy } from '../../util/iteratees'; @@ -220,7 +220,7 @@ function updateContainers() { } } - if (Object.keys(newMappedProps).length && !arePropsShallowEqual(mappedProps!, newMappedProps)) { + if (Object.keys(newMappedProps).length && !areRecordsShallowEqual(mappedProps!, newMappedProps)) { if (DEBUG_MORE) { logUnequalProps( mappedProps!, @@ -296,7 +296,7 @@ export function withUntypedGlobal( } if ( - (!container.mappedProps || !arePropsShallowEqual(container.ownProps, props)) + (!container.mappedProps || !areRecordsShallowEqual(container.ownProps, props)) && activateContainer(container, currentGlobal, props) ) { try { diff --git a/src/types/index.ts b/src/types/index.ts index b10e7a643..fe8183128 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -606,25 +606,47 @@ export interface TabThread { viewportIds?: number[]; } -export interface Thread { +export interface ThreadReadState { + unreadCount?: number; + unreadMentionsCount?: number; + unreadReactionsCount?: number; + unreadReactions?: number[]; + unreadMentions?: number[]; + hasUnreadMark?: boolean; + + lastReadOutboxMessageId?: number; + lastReadInboxMessageId?: number; +} + +export interface ThreadLocalState { lastScrollOffset?: number; lastViewportIds?: number[]; listedIds?: number[]; outlyingLists?: number[][]; pinnedIds?: number[]; scheduledIds?: number[]; + firstMessageId?: number; + editingId?: number; editingScheduledId?: number; editingDraft?: ApiFormattedText; editingScheduledDraft?: ApiFormattedText; + draft?: ApiDraft; + noWebPage?: boolean; - threadInfo?: ApiThreadInfo; - firstMessageId?: number; + typingStatus?: ApiTypingStatus; + typingDraftIdByRandomId?: Record; } +export interface Thread { + localState: ThreadLocalState; + threadInfo: ApiThreadInfo; + readState: ThreadReadState; +} + export interface ServiceNotification { id: number; message: ApiMessage; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 1be7eff45..b606e6da7 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1059,8 +1059,8 @@ export interface LangPair { 'ChatListPinToTop': undefined; 'ChatListOpenInNewWindow': undefined; 'ChatListOpenInNewTab': undefined; - 'ChatListContextMaskAsRead': undefined; - 'ChatListContextMaskAsUnread': undefined; + 'ChatListContextMarkAsRead': undefined; + 'ChatListContextMarkAsUnread': undefined; 'ChatListContextAddToFolder': undefined; 'Unarchive': undefined; 'Archive': undefined; @@ -1886,6 +1886,7 @@ export interface LangPair { 'SettingsBirthday': undefined; 'BotReadTextFromClipboardTitle': undefined; 'BotReadTextFromClipboardConfirm': undefined; + 'ChatInfoForumTopic': undefined; 'DiceToastSend': undefined; 'ChatTypePrivate': undefined; 'ChatTypeGroup': undefined; diff --git a/src/util/arePropsShallowEqual.ts b/src/util/areShallowEqual.ts similarity index 60% rename from src/util/arePropsShallowEqual.ts rename to src/util/areShallowEqual.ts index a6ba94531..db85f67c7 100644 --- a/src/util/arePropsShallowEqual.ts +++ b/src/util/areShallowEqual.ts @@ -1,4 +1,4 @@ -export default function arePropsShallowEqual(currentProps: AnyLiteral, newProps: AnyLiteral) { +export function areRecordsShallowEqual(currentProps: AnyLiteral, newProps: AnyLiteral) { if (currentProps === newProps) { return true; } @@ -25,6 +25,27 @@ export default function arePropsShallowEqual(currentProps: AnyLiteral, newProps: return true; } +export function areArraysShallowEqual(currentProps: unknown[], newProps: unknown[]) { + if (currentProps === newProps) { + return true; + } + + if (currentProps.length !== newProps.length) { + return false; + } + + return currentProps.every((item, i) => item === newProps[i]); +} + +export default function areShallowEqual(oldValue: T, newValue: T) { + if (oldValue === newValue) return true; + if (oldValue === undefined || newValue === undefined) return false; + if (Array.isArray(oldValue) && Array.isArray(newValue)) { + return areArraysShallowEqual(oldValue, newValue); + } + return areRecordsShallowEqual(oldValue, newValue); +} + export function logUnequalProps(currentProps: AnyLiteral, newProps: AnyLiteral, msg: string, debugKey = '') { const currentKeys = Object.keys(currentProps); const currentKeysLength = currentKeys.length; diff --git a/src/util/folderManager.ts b/src/util/folderManager.ts index 220b7aac0..1d55ca8dc 100644 --- a/src/util/folderManager.ts +++ b/src/util/folderManager.ts @@ -2,11 +2,12 @@ import { onFullyIdle } from '../lib/teact/teact'; import { addCallback } from '../lib/teact/teactn'; import { addActionHandler, getGlobal } from '../global'; -import type { - ApiChat, ApiChatFolder, ApiNotifyPeerType, ApiPeerNotifySettings, ApiUser, -} from '../api/types'; import type { GlobalState } from '../global/types'; import type { CallbackManager } from './callbacks'; +import { + type ApiChat, type ApiChatFolder, type ApiNotifyPeerType, type ApiPeerNotifySettings, type ApiUser, + MAIN_THREAD_ID, +} from '../api/types'; import { ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, DEBUG, SAVED_FOLDER_ID, SERVICE_NOTIFICATIONS_USER_ID, @@ -19,7 +20,8 @@ import { selectTabState, selectTopics, } from '../global/selectors'; -import arePropsShallowEqual from './arePropsShallowEqual'; +import { selectDraft, selectThreadReadState } from '../global/selectors/threads'; +import { areRecordsShallowEqual } from './areShallowEqual'; import { createCallbackManager } from './callbacks'; import { areSortedArraysEqual, unique } from './iteratees'; import { throttle } from './schedulers'; @@ -486,7 +488,7 @@ function updateChats( isRemovedFromSaved, ); - if (!areFoldersChanged && currentSummary && arePropsShallowEqual(newSummary, currentSummary)) { + if (!areFoldersChanged && currentSummary && areRecordsShallowEqual(newSummary, currentSummary)) { return; } @@ -533,16 +535,21 @@ function buildChatSummary( ): ChatSummary { const { id, type, isNotJoined, migratedTo, folderId, - unreadCount: chatUnreadCount, unreadMentionsCount: chatUnreadMentionsCount, hasUnreadMark, isForum, } = chat; + const draft = selectDraft(global, id, MAIN_THREAD_ID); const isRestricted = selectIsChatRestricted(global, id); const topics = selectTopics(global, chat.id); + const chatReadState = selectThreadReadState(global, chat.id, MAIN_THREAD_ID); + const { + unreadCount: chatUnreadCount, unreadMentionsCount: chatUnreadMentionsCount, hasUnreadMark, + } = chatReadState || {}; const { unreadCount, unreadMentionsCount } = isForum ? Object.values(topics || {}).reduce((acc, topic) => { - acc.unreadCount += topic.unreadCount; - acc.unreadMentionsCount += topic.unreadMentionsCount; + const topicReadState = selectThreadReadState(global, chat.id, topic.id); + acc.unreadCount += topicReadState?.unreadCount || 0; + acc.unreadMentionsCount += topicReadState?.unreadMentionsCount || 0; return acc; }, { unreadCount: 0, unreadMentionsCount: 0 }) @@ -554,7 +561,7 @@ function buildChatSummary( !lastMessage || lastMessage.content.action?.type === 'historyClear' ); - const orderInAll = Math.max(chat.creationDate || 0, chat.draftDate || 0, lastMessage?.date || 0); + const orderInAll = Math.max(chat.creationDate || 0, draft?.date || 0, lastMessage?.date || 0); const lastMessageInSaved = selectChatLastMessage(global, chat.id, 'saved'); const orderInSaved = lastMessageInSaved?.date || 0; @@ -731,7 +738,7 @@ function updateResults(affectedFolderIds: number[]) { const newUnreadCounters = buildFolderUnreadCounters(folderId); if (!wasUnreadCountersChanged) { wasUnreadCountersChanged = ( - !currentUnreadCounters || !arePropsShallowEqual(newUnreadCounters, currentUnreadCounters) + !currentUnreadCounters || !areRecordsShallowEqual(newUnreadCounters, currentUnreadCounters) ); } results.unreadCountersByFolderId[folderId] = newUnreadCounters; diff --git a/src/util/iteratees.ts b/src/util/iteratees.ts index a966bd470..78b3c128c 100644 --- a/src/util/iteratees.ts +++ b/src/util/iteratees.ts @@ -14,7 +14,7 @@ export function buildCollectionByKey(collection: T[], key: }, {}); } -export function buildCollectionByCallback( +export function buildCollectionByCallback( collection: T[], callback: (member: T) => [K, R], ) { @@ -36,6 +36,19 @@ export function mapValues( }, {}); } +export function mapTruthyValues( + byKey: CollectionByKey, + callback: (member: M, key: string, index: number, originalByKey: CollectionByKey) => R | Falsy, +): CollectionByKey { + return Object.keys(byKey).reduce((newByKey: CollectionByKey, key, index) => { + const value = callback(byKey[key], key, index, byKey); + if (value) { + newByKey[key] = value; + } + return newByKey; + }, {}); +} + export function pick(object: T, keys: K[]) { return keys.reduce((result, key) => { result[key] = object[key]; @@ -109,7 +122,7 @@ export function uniqueByField(array: T[], field: keyof T): T[] { return [...new Map(array.map((item) => [item[field], item])).values()]; } -export function compact(array: T[]) { +export function compact(array: (T | Falsy)[]): T[] { return array.filter(Boolean); }