Forum: Support mentions and reactions (#6489)

This commit is contained in:
zubiden 2026-02-22 23:42:38 +01:00 committed by Alexander Zinchuk
parent 00f7da84a3
commit b68ae94f3a
84 changed files with 2189 additions and 1648 deletions

View File

@ -304,7 +304,7 @@ Global State is our single, app-wide store, similar to Redux or Zustand. All its
### 1. Accessing Global in Components ### 1. Accessing Global in Components
* **Use** `withGlobal` (a `mapStateToProps` helper) to pull in state. * **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). * **Use** `getGlobal` **only** inside hooks for one-off reads (it's non-reactive).
### 2. Performance ### 2. Performance

View File

@ -1,28 +1,31 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import type { Entity } from '../../../lib/gramjs/types'; import type { Entity } from '../../../lib/gramjs/types';
import type { import type { ThreadReadState } from '../../../types';
ApiBotCommand, import {
ApiChat, type ApiBotCommand,
ApiChatAdminRights, type ApiChat,
ApiChatBannedRights, type ApiChatAdminRights,
ApiChatFolder, type ApiChatBannedRights,
ApiChatInviteImporter, type ApiChatFolder,
ApiChatInviteInfo, type ApiChatInviteImporter,
ApiChatlistExportedInvite, type ApiChatInviteInfo,
ApiChatlistInvite, type ApiChatlistExportedInvite,
ApiChatMember, type ApiChatlistInvite,
ApiChatReactions, type ApiChatMember,
ApiExportedInvite, type ApiChatReactions,
ApiMissingInvitedUser, type ApiExportedInvite,
ApiRestrictionReason, type ApiMissingInvitedUser,
ApiSendAsPeerId, type ApiRestrictionReason,
ApiSponsoredMessageReportResult, type ApiSendAsPeerId,
ApiSponsoredPeer, type ApiSponsoredMessageReportResult,
ApiStarsSubscriptionPricing, type ApiSponsoredPeer,
type ApiStarsSubscriptionPricing,
type ApiThreadInfo,
MAIN_THREAD_ID,
} from '../../types'; } from '../../types';
import { pickTruthy } from '../../../util/iteratees'; import { omitUndefined, pickTruthy } from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers'; import { toJSNumber } from '../../../util/numbers';
import { getServerTimeOffset } from '../../../util/serverTime'; import { getServerTimeOffset } from '../../../util/serverTime';
import { addPhotoToLocalDb, addUserToLocalDb } from '../helpers/localDb'; import { addPhotoToLocalDb, addUserToLocalDb } from '../helpers/localDb';
@ -144,23 +147,15 @@ export function buildApiChatFromDialog(
peerEntity: GramJs.TypeUser | GramJs.TypeChat, peerEntity: GramJs.TypeUser | GramJs.TypeChat,
): ApiChat { ): ApiChat {
const { const {
peer, folderId, unreadMark, unreadCount, unreadMentionsCount, unreadReactionsCount, peer, folderId, viewForumAsMessages,
readOutboxMaxId, readInboxMaxId, draft, viewForumAsMessages,
} = dialog; } = dialog;
return { return {
id: getApiChatIdFromMtpPeer(peer), id: getApiChatIdFromMtpPeer(peer),
...(folderId && { folderId }), folderId,
type: getApiChatTypeFromPeerEntity(peerEntity), type: getApiChatTypeFromPeerEntity(peerEntity),
title: getApiChatTitleFromMtpPeer(peer, peerEntity), title: getApiChatTitleFromMtpPeer(peer, peerEntity),
lastReadOutboxMessageId: readOutboxMaxId, isForumAsMessages: viewForumAsMessages,
lastReadInboxMessageId: readInboxMaxId,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
...(unreadMark && { hasUnreadMark: true }),
...(draft instanceof GramJs.DraftMessage && { draftDate: draft.date }),
...(viewForumAsMessages && { isForumAsMessages: true }),
...buildApiChatFieldsFromPeerEntity(peerEntity), ...buildApiChatFieldsFromPeerEntity(peerEntity),
}; };
} }
@ -705,3 +700,38 @@ export function buildApiSponsoredPeer(sponsoredPeer: GramJs.SponsoredPeer): ApiS
sponsorInfo, 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<ThreadReadState>({
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,
};
}

View File

@ -1,10 +1,11 @@
import { Api as GramJs } from '../../../lib/gramjs'; 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'; 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) { if (forumTopic instanceof GramJs.ForumTopicDeleted) {
return undefined; return undefined;
} }
@ -20,10 +21,6 @@ export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | und
title, title,
iconColor, iconColor,
iconEmojiId, iconEmojiId,
topMessage,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
fromId, fromId,
notifySettings, notifySettings,
titleMissing, titleMissing,
@ -40,12 +37,24 @@ export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | und
title, title,
iconColor, iconColor,
iconEmojiId: iconEmojiId?.toString(), iconEmojiId: iconEmojiId?.toString(),
lastMessageId: topMessage,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
fromId: getApiChatIdFromMtpPeer(fromId), fromId: getApiChatIdFromMtpPeer(fromId),
notifySettings: buildApiPeerNotifySettings(notifySettings), notifySettings: buildApiPeerNotifySettings(notifySettings),
isTitleMissing: titleMissing, 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,
};
}

View File

@ -1,24 +1,26 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { RPCError } from '../../../lib/gramjs/errors'; import { RPCError } from '../../../lib/gramjs/errors';
import type { ChatListType } from '../../../types'; import type { ChatListType, ThreadReadState } from '../../../types';
import type { import {
ApiChat, type ApiChat,
ApiChatAdminRights, type ApiChatAdminRights,
ApiChatBannedRights, type ApiChatBannedRights,
ApiChatFolder, type ApiChatFolder,
ApiChatFullInfo, type ApiChatFullInfo,
ApiChatReactions, type ApiChatReactions,
ApiDraft, type ApiDraft,
ApiGroupCall, type ApiGroupCall,
ApiMessage, type ApiMessage,
ApiMissingInvitedUser, type ApiMissingInvitedUser,
ApiPeer, type ApiPeer,
ApiPeerNotifySettings, type ApiPeerNotifySettings,
ApiPhoto, type ApiPhoto,
ApiProfileTab, type ApiProfileTab,
ApiUser, type ApiThreadInfo,
ApiUserStatus, type ApiUser,
type ApiUserStatus,
MAIN_THREAD_ID,
} from '../../types'; } from '../../types';
import { import {
@ -43,8 +45,10 @@ import {
buildApiChatReactions, buildApiChatReactions,
buildApiMissingInvitedUser, buildApiMissingInvitedUser,
buildApiSponsoredPeer, buildApiSponsoredPeer,
buildApiThreadInfoFromDialog,
buildChatMember, buildChatMember,
buildChatMembers, buildChatMembers,
buildThreadReadState,
getPeerKey, getPeerKey,
} from '../apiBuilders/chats'; } from '../apiBuilders/chats';
import { buildApiPhoto } from '../apiBuilders/common'; import { buildApiPhoto } from '../apiBuilders/common';
@ -101,6 +105,8 @@ type ChatListData = {
users: ApiUser[]; users: ApiUser[];
userStatusesById: Record<string, ApiUserStatus>; userStatusesById: Record<string, ApiUserStatus>;
draftsById: Record<string, ApiDraft>; draftsById: Record<string, ApiDraft>;
threadReadStatesById?: Record<string, ThreadReadState>;
threadInfos: ApiThreadInfo[];
orderedPinnedIds: string[] | undefined; orderedPinnedIds: string[] | undefined;
totalChatCount: number; totalChatCount: number;
messages: ApiMessage[]; messages: ApiMessage[];
@ -161,6 +167,8 @@ export async function fetchChats({
const chats: ApiChat[] = []; const chats: ApiChat[] = [];
const draftsById: Record<string, ApiDraft> = {}; const draftsById: Record<string, ApiDraft> = {};
const notifyExceptionById: Record<string, ApiPeerNotifySettings> = {}; const notifyExceptionById: Record<string, ApiPeerNotifySettings> = {};
const threadReadStatesById: Record<string, ThreadReadState> = {};
const threadInfos: ApiThreadInfo[] = [];
const dialogs = (resultPinned?.dialogs || []).concat(result.dialogs); const dialogs = (resultPinned?.dialogs || []).concat(result.dialogs);
@ -216,6 +224,12 @@ export async function fetchChats({
draftsById[chat.id] = draft; 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); const chatIds = chats.map((chat) => chat.id);
@ -251,26 +265,33 @@ export async function fetchChats({
nextOffsetId, nextOffsetId,
nextOffsetPeerId, nextOffsetPeerId,
nextOffsetDate, nextOffsetDate,
threadReadStatesById,
threadInfos,
}; };
} }
export async function fetchSavedChats({ export async function fetchSavedChats({
parentPeer,
limit, limit,
offsetDate, offsetDate,
offsetPeer, offsetPeer,
offsetId, offsetId,
withPinned, withPinned,
}: { }: {
parentPeer: ApiPeer;
limit: number; limit: number;
offsetDate?: number; offsetDate?: number;
offsetPeer?: ApiPeer; offsetPeer?: ApiPeer;
offsetId?: number; offsetId?: number;
withPinned?: boolean; withPinned?: boolean;
}): Promise<ChatListData | undefined> { }): Promise<ChatListData | undefined> {
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 peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty();
const result = await invokeRequest(new GramJs.messages.GetSavedDialogs({ const result = await invokeRequest(new GramJs.messages.GetSavedDialogs({
offsetPeer: peer, offsetPeer: peer,
offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT, offsetId: offsetId ?? DEFAULT_PRIMITIVES.INT,
parentPeer: inputParentPeer,
limit, limit,
offsetDate: offsetDate ?? DEFAULT_PRIMITIVES.INT, offsetDate: offsetDate ?? DEFAULT_PRIMITIVES.INT,
hash: DEFAULT_PRIMITIVES.BIGINT, hash: DEFAULT_PRIMITIVES.BIGINT,
@ -301,6 +322,7 @@ export async function fetchSavedChats({
const chatIds: string[] = []; const chatIds: string[] = [];
const orderedPinnedIds: string[] = []; const orderedPinnedIds: string[] = [];
const lastMessageByChatId: Record<string, number> = {}; const lastMessageByChatId: Record<string, number> = {};
const threadInfos: ApiThreadInfo[] = [];
const chats: ApiChat[] = []; const chats: ApiChat[] = [];
@ -321,6 +343,9 @@ export async function fetchSavedChats({
lastMessageByChatId[chatId] = dialog.topMessage; lastMessageByChatId[chatId] = dialog.topMessage;
chats.push(chat); chats.push(chat);
const threadInfo = buildApiThreadInfoFromDialog(parentPeer.id, dialog);
threadInfos.push(threadInfo);
}); });
const users = result.users.map(buildApiUser).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean);
@ -355,6 +380,7 @@ export async function fetchSavedChats({
nextOffsetId, nextOffsetId,
nextOffsetPeerId, nextOffsetPeerId,
nextOffsetDate, nextOffsetDate,
threadInfos,
}; };
} }
@ -496,6 +522,14 @@ export async function requestChatUpdate({
const chatUpdate = buildApiChatFromDialog(dialog, peerEntity); const chatUpdate = buildApiChatFromDialog(dialog, peerEntity);
const readState = buildThreadReadState(dialog);
sendApiUpdate({
'@type': 'updateThreadReadState',
chatId: id,
threadId: MAIN_THREAD_ID,
readState,
});
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChat', '@type': 'updateChat',
id, id,
@ -1195,7 +1229,7 @@ export function toggleDialogFilterTags(isEnabled: boolean) {
export async function toggleDialogUnread({ export async function toggleDialogUnread({
chat, hasUnreadMark, chat, hasUnreadMark,
}: { }: {
chat: ApiChat; hasUnreadMark: boolean | undefined; chat: ApiChat; hasUnreadMark?: true;
}) { }) {
const { id, accessHash } = chat; const { id, accessHash } = chat;
@ -1203,14 +1237,17 @@ export async function toggleDialogUnread({
peer: new GramJs.InputDialogPeer({ peer: new GramJs.InputDialogPeer({
peer: buildInputPeer(id, accessHash), peer: buildInputPeer(id, accessHash),
}), }),
unread: hasUnreadMark || undefined, unread: hasUnreadMark,
})); }));
if (isActionSuccessful) { if (isActionSuccessful) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChat', '@type': 'updateThreadReadState',
id: chat.id, chatId: chat.id,
chat: { hasUnreadMark }, threadId: MAIN_THREAD_ID,
readState: {
hasUnreadMark,
},
}); });
} }
} }

View File

@ -1,7 +1,7 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { generateRandomBigInt } from '../../../lib/gramjs/Helpers'; import { generateRandomBigInt } from '../../../lib/gramjs/Helpers';
import type { ApiMessage, ApiTopic } from '../../types'; import type { ApiMessage, ApiTopicWithState } from '../../types';
import type { import type {
ApiChat, ApiChat,
ApiDraft, ApiDraft,
@ -9,7 +9,7 @@ import type {
} from '../../types/chats'; } from '../../types/chats';
import { GENERAL_TOPIC_ID, TOPICS_SLICE } from '../../../config'; 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 { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages';
import { buildInputPeer, DEFAULT_PRIMITIVES } from '../gramjsBuilders'; import { buildInputPeer, DEFAULT_PRIMITIVES } from '../gramjsBuilders';
import { processAffectedHistory } from '../updates/updateManager'; import { processAffectedHistory } from '../updates/updateManager';
@ -57,12 +57,11 @@ export async function fetchTopics({
offsetDate?: number; offsetDate?: number;
limit?: number; limit?: number;
}): Promise<{ }): Promise<{
topics: ApiTopic[]; topics: ApiTopicWithState[];
messages: ApiMessage[]; messages: ApiMessage[];
count: number; count: number;
shouldOrderByCreateDate?: boolean; shouldOrderByCreateDate?: boolean;
draftsById: Record<number, ApiDraft | undefined>; draftsById: Record<number, ApiDraft | undefined>;
readInboxMessageIdByTopicId: Record<number, number>;
} | undefined> { } | undefined> {
const { id, accessHash } = chat; const { id, accessHash } = chat;
@ -79,7 +78,7 @@ export async function fetchTopics({
const { orderByCreateDate } = result; 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 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 messages = result.messages.map(buildApiMessage).filter(Boolean);
const draftsById = result.topics.reduce((acc, topic) => { const draftsById = result.topics.reduce((acc, topic) => {
@ -88,12 +87,6 @@ export async function fetchTopics({
} }
return acc; return acc;
}, {} as Record<number, ReturnType<typeof buildMessageDraft>>); }, {} as Record<number, ReturnType<typeof buildMessageDraft>>);
const readInboxMessageIdByTopicId = result.topics.reduce((acc, topic) => {
if (topic instanceof GramJs.ForumTopic && topic.readInboxMaxId) {
acc[topic.id] = topic.readInboxMaxId;
}
return acc;
}, {} as Record<number, number>);
return { return {
topics, topics,
@ -102,7 +95,6 @@ export async function fetchTopics({
count: count + 1, count: count + 1,
shouldOrderByCreateDate: orderByCreateDate, shouldOrderByCreateDate: orderByCreateDate,
draftsById, draftsById,
readInboxMessageIdByTopicId,
}; };
} }
@ -112,7 +104,7 @@ export async function fetchTopicById({
chat: ApiChat; chat: ApiChat;
topicId: number; topicId: number;
}): Promise<{ }): Promise<{
topic: ApiTopic; topic: ApiTopicWithState;
messages: ApiMessage[]; messages: ApiMessage[];
} | undefined> { } | undefined> {
const { id, accessHash } = chat; const { id, accessHash } = chat;
@ -129,7 +121,7 @@ export async function fetchTopicById({
const messages = result.messages.map(buildApiMessage).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean);
return { return {
topic: buildApiTopic(result.topics[0])!, topic: buildApiTopicWithState(result.topics[0])!,
messages, messages,
}; };
} }

View File

@ -27,6 +27,7 @@ import type {
ApiSearchPostsFlood, ApiSearchPostsFlood,
ApiSendMessageAction, ApiSendMessageAction,
ApiTodoItem, ApiTodoItem,
ApiTopicWithState,
ApiUser, ApiUser,
ApiUserStatus, ApiUserStatus,
ApiWebPage, ApiWebPage,
@ -57,8 +58,10 @@ import {
buildApiChatFromPreview, buildApiChatFromPreview,
buildApiSendAsPeerId, buildApiSendAsPeerId,
buildApiSponsoredMessageReportResult, buildApiSponsoredMessageReportResult,
buildThreadReadState,
} from '../apiBuilders/chats'; } from '../apiBuilders/chats';
import { buildApiFormattedText } from '../apiBuilders/common'; import { buildApiFormattedText } from '../apiBuilders/common';
import { buildApiTopicWithState } from '../apiBuilders/forums';
import { import {
buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia, buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia,
buildWebPageFromMedia, buildWebPageFromMedia,
@ -126,6 +129,7 @@ type TranslateTextParams = ({
type SearchResults = { type SearchResults = {
messages: ApiMessage[]; messages: ApiMessage[];
topics: ApiTopicWithState[];
userStatusesById: Record<number, ApiUserStatus>; userStatusesById: Record<number, ApiUserStatus>;
totalCount: number; totalCount: number;
nextOffsetRate?: number; nextOffsetRate?: number;
@ -196,13 +200,15 @@ export async function fetchMessages({
const messages = result.messages.map(buildApiMessage).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean);
const users = result.users.map(buildApiUser).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).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 { return {
messages, messages,
users, users,
chats, chats,
count, count,
topics,
}; };
} }
@ -371,6 +377,7 @@ export function sendApiMessage(
message: { message: {
sendingState: 'messageSendingStatePending', sendingState: 'messageSendingStatePending',
}, },
isFull: false,
}); });
}, FAST_SEND_TIMEOUT); }, FAST_SEND_TIMEOUT);
@ -740,7 +747,7 @@ export async function editMessage({
}), }),
}; };
const messageUpdate: Partial<ApiMessage> = { const messageUpdate: ApiMessage = {
...message, ...message,
content: newContent, content: newContent,
isInvertedMedia, isInvertedMedia,
@ -751,6 +758,7 @@ export async function editMessage({
id: message.id, id: message.id,
chatId: chat.id, chatId: chat.id,
message: messageUpdate, message: messageUpdate,
isFull: true,
}); });
try { try {
@ -794,6 +802,7 @@ export async function editMessage({
id: message.id, id: message.id,
chatId: chat.id, chatId: chat.id,
message, message,
isFull: true,
}); });
} }
} }
@ -818,7 +827,7 @@ export async function editTodo({
}, },
}; };
const messageUpdate: Partial<ApiMessage> = { const messageUpdate: ApiMessage = {
...message, ...message,
content: newContent, content: newContent,
}; };
@ -828,6 +837,7 @@ export async function editTodo({
id: message.id, id: message.id,
chatId: chat.id, chatId: chat.id,
message: messageUpdate, message: messageUpdate,
isFull: true,
}); });
try { try {
@ -858,6 +868,7 @@ export async function editTodo({
id: message.id, id: message.id,
chatId: chat.id, chatId: chat.id,
message, message,
isFull: true,
}); });
} }
} }
@ -1447,17 +1458,14 @@ export async function fetchDiscussionMessage({
if (!threadId) return undefined; if (!threadId) return undefined;
const { const { maxId } = result;
unreadCount, maxId, readInboxMaxId, readOutboxMaxId, const threadReadState = buildThreadReadState(result);
} = result;
return { return {
messages, messages,
topMessages, topMessages,
unreadCount,
threadId, threadId,
lastReadInboxMessageId: readInboxMaxId, threadReadState,
lastReadOutboxMessageId: readOutboxMaxId,
lastMessageId: maxId, lastMessageId: maxId,
chatId: topMessages[0]?.chatId, chatId: topMessages[0]?.chatId,
firstMessageId: replies.messages[0]?.id, firstMessageId: replies.messages[0]?.id,
@ -1554,6 +1562,7 @@ export async function searchMessagesInChat({
const userStatusesById = buildApiUserStatuses(result.users); const userStatusesById = buildApiUserStatuses(result.users);
const messages = result.messages.map(buildApiMessage).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean);
const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);
let totalCount = messages.length; let totalCount = messages.length;
let nextOffsetId: number | undefined; let nextOffsetId: number | undefined;
@ -1568,6 +1577,7 @@ export async function searchMessagesInChat({
return { return {
userStatusesById, userStatusesById,
messages, messages,
topics,
totalCount, totalCount,
nextOffsetId, nextOffsetId,
}; };
@ -1649,6 +1659,7 @@ export async function searchMessagesGlobal({
const userStatusesById = buildApiUserStatuses(result.users); const userStatusesById = buildApiUserStatuses(result.users);
const messages = result.messages.map(buildApiMessage).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean);
const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);
let totalCount = messages.length; let totalCount = messages.length;
if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) { if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) {
@ -1664,6 +1675,7 @@ export async function searchMessagesGlobal({
return { return {
messages, messages,
topics,
userStatusesById, userStatusesById,
totalCount, totalCount,
nextOffsetRate, nextOffsetRate,
@ -1706,6 +1718,7 @@ export async function searchPublicPosts({
const userStatusesById = buildApiUserStatuses(result.users); const userStatusesById = buildApiUserStatuses(result.users);
const messages = result.messages.map(buildApiMessage).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean);
const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);
let totalCount = messages.length; let totalCount = messages.length;
if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) { if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) {
@ -1725,6 +1738,7 @@ export async function searchPublicPosts({
return { return {
messages, messages,
topics,
userStatusesById, userStatusesById,
totalCount, totalCount,
nextOffsetRate, nextOffsetRate,
@ -2248,10 +2262,14 @@ export async function fetchUnreadMentions({
return undefined; return undefined;
} }
const totalCount = 'count' in result ? result.count : result.messages.length;
const messages = result.messages.map(buildApiMessage).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean);
const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);
return { return {
totalCount,
messages, messages,
topics,
}; };
} }
@ -2283,10 +2301,14 @@ export async function fetchUnreadReactions({
return undefined; return undefined;
} }
const totalCount = 'count' in result ? result.count : result.messages.length;
const messages = result.messages.map(buildApiMessage).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean);
const topics = result.topics.map(buildApiTopicWithState).filter(Boolean);
return { return {
totalCount,
messages, messages,
topics,
}; };
} }

View File

@ -2,10 +2,11 @@ import { Api as GramJs, type Update } from '../../../lib/gramjs';
import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network'; import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network';
import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; import type { GroupCallConnectionData } from '../../../lib/secret-sauce';
import type { import {
ApiMessage, ApiPoll, ApiStory, ApiStorySkipped, type ApiMessage, type ApiPoll, type ApiStory, type ApiStorySkipped,
ApiUpdateConnectionStateType, type ApiUpdateConnectionStateType,
ApiWebPage, type ApiWebPage,
MAIN_THREAD_ID,
} from '../../types'; } from '../../types';
import { DEBUG, GENERAL_TOPIC_ID } from '../../../config'; import { DEBUG, GENERAL_TOPIC_ID } from '../../../config';
@ -176,6 +177,7 @@ export function updater(update: Update) {
poll, poll,
webPage, webPage,
isFromNew: true, isFromNew: true,
isFull: true,
}); });
} else { } else {
sendApiUpdate({ sendApiUpdate({
@ -187,6 +189,7 @@ export function updater(update: Update) {
poll, poll,
webPage, webPage,
isFromNew: true, isFromNew: true,
isFull: true,
}); });
} }
@ -352,7 +355,7 @@ export function updater(update: Update) {
processMessageAndUpdateThreadInfo(mtpMessage); processMessageAndUpdateThreadInfo(mtpMessage);
// Workaround for a weird server behavior when own message is marked as incoming // 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 const poll = mtpMessage instanceof GramJs.Message && mtpMessage.media
? buildPollFromMedia(mtpMessage.media) : undefined; ? buildPollFromMedia(mtpMessage.media) : undefined;
@ -367,11 +370,13 @@ export function updater(update: Update) {
message, message,
poll, poll,
webPage, webPage,
isFull: true,
}); });
} else if (update instanceof GramJs.UpdateMessageReactions) { } else if (update instanceof GramJs.UpdateMessageReactions) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateMessageReactions', '@type': 'updateMessageReactions',
id: update.msgId, id: update.msgId,
threadId: update.topMsgId,
chatId: getApiChatIdFromMtpPeer(update.peer), chatId: getApiChatIdFromMtpPeer(update.peer),
reactions: buildMessageReactions(update.reactions), reactions: buildMessageReactions(update.reactions),
}); });
@ -508,51 +513,57 @@ export function updater(update: Update) {
// Chats // Chats
} else if (update instanceof GramJs.UpdateReadHistoryInbox) { } else if (update instanceof GramJs.UpdateReadHistoryInbox) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChatInbox', '@type': 'updateThreadReadState',
id: getApiChatIdFromMtpPeer(update.peer), chatId: getApiChatIdFromMtpPeer(update.peer),
lastReadInboxMessageId: update.maxId, threadId: update.topMsgId || MAIN_THREAD_ID,
unreadCount: update.stillUnreadCount, readState: {
threadId: update.topMsgId, lastReadInboxMessageId: update.maxId,
unreadCount: update.stillUnreadCount,
},
}); });
} else if (update instanceof GramJs.UpdateReadHistoryOutbox) { } else if (update instanceof GramJs.UpdateReadHistoryOutbox) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChat', '@type': 'updateThreadReadState',
id: getApiChatIdFromMtpPeer(update.peer), chatId: getApiChatIdFromMtpPeer(update.peer),
chat: { threadId: MAIN_THREAD_ID,
readState: {
lastReadOutboxMessageId: update.maxId, lastReadOutboxMessageId: update.maxId,
}, },
}); });
} else if (update instanceof GramJs.UpdateReadChannelInbox) { } else if (update instanceof GramJs.UpdateReadChannelInbox) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChat', '@type': 'updateThreadReadState',
id: buildApiPeerId(update.channelId, 'channel'), chatId: buildApiPeerId(update.channelId, 'channel'),
chat: { threadId: MAIN_THREAD_ID,
readState: {
lastReadInboxMessageId: update.maxId, lastReadInboxMessageId: update.maxId,
unreadCount: update.stillUnreadCount, unreadCount: update.stillUnreadCount,
}, },
}); });
} else if (update instanceof GramJs.UpdateReadChannelOutbox) { } else if (update instanceof GramJs.UpdateReadChannelOutbox) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChat', '@type': 'updateThreadReadState',
id: buildApiPeerId(update.channelId, 'channel'), chatId: buildApiPeerId(update.channelId, 'channel'),
chat: { threadId: MAIN_THREAD_ID,
readState: {
lastReadOutboxMessageId: update.maxId, lastReadOutboxMessageId: update.maxId,
}, },
}); });
} else if (update instanceof GramJs.UpdateReadChannelDiscussionInbox) { } else if (update instanceof GramJs.UpdateReadChannelDiscussionInbox) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateThreadInfo', '@type': 'updateThreadReadState',
threadInfo: { chatId: buildApiPeerId(update.channelId, 'channel'),
chatId: buildApiPeerId(update.channelId, 'channel'), threadId: update.topMsgId,
threadId: update.topMsgId, readState: {
lastReadInboxMessageId: update.readMaxId, lastReadInboxMessageId: update.readMaxId,
}, },
}); });
} else if (update instanceof GramJs.UpdateReadChannelDiscussionOutbox) { } else if (update instanceof GramJs.UpdateReadChannelDiscussionOutbox) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChat', '@type': 'updateThreadReadState',
id: buildApiPeerId(update.channelId, 'channel'), chatId: buildApiPeerId(update.channelId, 'channel'),
chat: { threadId: update.topMsgId,
readState: {
lastReadOutboxMessageId: update.readMaxId, lastReadOutboxMessageId: update.readMaxId,
}, },
}); });
@ -752,9 +763,10 @@ export function updater(update: Update) {
&& update.peer instanceof GramJs.DialogPeer && update.peer instanceof GramJs.DialogPeer
) { ) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateChat', '@type': 'updateThreadReadState',
id: getApiChatIdFromMtpPeer(update.peer.peer), chatId: getApiChatIdFromMtpPeer(update.peer.peer),
chat: { threadId: MAIN_THREAD_ID,
readState: {
hasUnreadMark: update.unread, hasUnreadMark: update.unread,
}, },
}); });

View File

@ -28,12 +28,6 @@ export interface ApiChat {
folderId?: number; folderId?: number;
type: ApiChatType; type: ApiChatType;
title: string; title: string;
hasUnreadMark?: boolean;
lastReadOutboxMessageId?: number;
lastReadInboxMessageId?: number;
unreadCount?: number;
unreadMentionsCount?: number;
unreadReactionsCount?: number;
isVerified?: true; isVerified?: true;
areSignaturesShown?: boolean; areSignaturesShown?: boolean;
areProfilesShown?: boolean; areProfilesShown?: boolean;
@ -48,7 +42,6 @@ export interface ApiChat {
membersCount?: number; membersCount?: number;
creationDate?: number; creationDate?: number;
isSupport?: true; isSupport?: true;
draftDate?: number;
isProtected?: boolean; isProtected?: boolean;
fakeType?: ApiFakeType; fakeType?: ApiFakeType;
color?: ApiTypePeerColor; color?: ApiTypePeerColor;
@ -93,9 +86,6 @@ export interface ApiChat {
sendPaidReactionsAsPeerIds?: ApiSendAsPeerId[]; sendPaidReactionsAsPeerIds?: ApiSendAsPeerId[];
sendPaidReactionsPeer?: ApiSendAsPeerId; sendPaidReactionsPeer?: ApiSendAsPeerId;
unreadReactions?: number[];
unreadMentions?: number[];
// Stories // Stories
areStoriesHidden?: boolean; areStoriesHidden?: boolean;
hasStories?: boolean; hasStories?: boolean;

View File

@ -1,4 +1,4 @@
import type { ThreadId, WebPageMediaSize } from '../../types'; import type { ThreadId, ThreadReadState, WebPageMediaSize } from '../../types';
import type { import type {
ApiBotInlineMediaResult, ApiBotInlineMediaResult,
ApiBotInlineResult, ApiBotInlineResult,
@ -813,11 +813,10 @@ export type PaidReactionPrivacyPeer = {
peerId: string; peerId: string;
}; };
interface ApiBaseThreadInfo { export interface ApiBaseThreadInfo {
chatId: string; chatId: string;
messagesCount: number; messagesCount?: number;
lastMessageId?: number; lastMessageId?: number;
lastReadInboxMessageId?: number;
recentReplierIds?: string[]; recentReplierIds?: string[];
} }
@ -1085,22 +1084,22 @@ export interface ApiTopic {
isPinned?: boolean; isPinned?: boolean;
isHidden?: boolean; isHidden?: boolean;
isOwner?: boolean; isOwner?: boolean;
// TODO[forums] https://github.com/telegramdesktop/tdesktop/blob/1aece79a471d99a8b63d826b1bce1f36a04d7293/Telegram/SourceFiles/data/data_forum_topic.cpp#L318
isMin?: boolean; isMin?: boolean;
date: number; date: number;
title: string; title: string;
iconColor: number; iconColor: number;
iconEmojiId?: string; iconEmojiId?: string;
lastMessageId: number;
unreadCount: number;
unreadMentionsCount: number;
unreadReactionsCount: number;
fromId: string; fromId: string;
notifySettings: ApiPeerNotifySettings; notifySettings: ApiPeerNotifySettings;
isTitleMissing?: boolean; isTitleMissing?: boolean;
} }
export interface ApiTopicWithState {
topic: ApiTopic;
readState?: ThreadReadState;
lastMessageId?: number;
}
export const MAIN_THREAD_ID = -1; export const MAIN_THREAD_ID = -1;
// `Symbol` can not be transferred from worker // `Symbol` can not be transferred from worker

View File

@ -6,7 +6,7 @@ import type {
VideoRotation, VideoRotation,
VideoState, VideoState,
} from '../../lib/secret-sauce'; } from '../../lib/secret-sauce';
import type { ThreadId } from '../../types'; import type { ThreadId, ThreadReadState } from '../../types';
import type { RegularLangFnParameters } from '../../util/localization'; import type { RegularLangFnParameters } from '../../util/localization';
import type { ApiBotCommand, ApiBotMenuButton } from './bots'; import type { ApiBotCommand, ApiBotMenuButton } from './bots';
import type { import type {
@ -122,6 +122,7 @@ export type ApiUpdateChat = {
'@type': 'updateChat'; '@type': 'updateChat';
id: string; id: string;
chat: Partial<ApiChat>; chat: Partial<ApiChat>;
readState?: Partial<ThreadReadState>;
noTopChatsRequest?: boolean; noTopChatsRequest?: boolean;
}; };
@ -141,14 +142,6 @@ export type ApiUpdateChatLeave = {
id: string; id: string;
}; };
export type ApiUpdateChatInbox = {
'@type': 'updateChatInbox';
id: string;
threadId?: ThreadId;
lastReadInboxMessageId: number;
unreadCount: number;
};
export type ApiUpdateChatTypingStatus = { export type ApiUpdateChatTypingStatus = {
'@type': 'updateChatTypingStatus'; '@type': 'updateChatTypingStatus';
id: string; id: string;
@ -245,7 +238,7 @@ export type ApiUpdateNewMessage = {
'@type': 'newMessage'; '@type': 'newMessage';
chatId: string; chatId: string;
id: number; id: number;
message: Partial<ApiMessage>; message: ApiMessage;
shouldForceReply?: boolean; shouldForceReply?: boolean;
wasDrafted?: boolean; wasDrafted?: boolean;
poll?: ApiPoll; poll?: ApiPoll;
@ -256,22 +249,36 @@ export type ApiUpdateMessage = {
'@type': 'updateMessage'; '@type': 'updateMessage';
chatId: string; chatId: string;
id: number; id: number;
message: Partial<ApiMessage>;
poll?: ApiPoll; poll?: ApiPoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
shouldForceReply?: boolean; shouldForceReply?: boolean;
isFromNew?: true; isFromNew?: true;
}; } & (
{
isFull: true;
message: ApiMessage;
} | {
isFull?: false;
message: Partial<ApiMessage>;
}
);
export type ApiUpdateScheduledMessage = { export type ApiUpdateScheduledMessage = {
'@type': 'updateScheduledMessage'; '@type': 'updateScheduledMessage';
chatId: string; chatId: string;
id: number; id: number;
message: Partial<ApiMessage>;
poll?: ApiPoll; poll?: ApiPoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
isFromNew?: true; isFromNew?: true;
}; } & (
{
isFull: true;
message: ApiMessage;
} | {
isFull?: false;
message: Partial<ApiMessage>;
}
);
export type ApiUpdateQuickReplyMessage = { export type ApiUpdateQuickReplyMessage = {
'@type': 'updateQuickReplyMessage'; '@type': 'updateQuickReplyMessage';
@ -306,7 +313,14 @@ export type ApiUpdatePinnedMessageIds = {
export type ApiUpdateThreadInfo = { export type ApiUpdateThreadInfo = {
'@type': 'updateThreadInfo'; '@type': 'updateThreadInfo';
threadInfo: Partial<ApiThreadInfo>; threadInfo: ApiThreadInfo;
};
export type ApiUpdateThreadReadState = {
'@type': 'updateThreadReadState';
chatId: string;
threadId: ThreadId;
readState: Partial<ThreadReadState>;
}; };
export type ApiUpdateScheduledMessageSendSucceeded = { export type ApiUpdateScheduledMessageSendSucceeded = {
@ -424,6 +438,7 @@ export type ApiUpdateMessageReactions = {
'@type': 'updateMessageReactions'; '@type': 'updateMessageReactions';
id: number; id: number;
chatId: string; chatId: string;
threadId?: ThreadId;
reactions: ApiReactions; reactions: ApiReactions;
}; };
@ -898,7 +913,7 @@ export type ApiUpdateWebPage = {
export type ApiUpdate = ( export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate | ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
ApiUpdateChat | ApiUpdateChatInbox | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds | ApiUpdateChat | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds |
ApiUpdateChatMembers | ApiUpdateChatJoin | ApiUpdateChatLeave | ApiUpdateChatPinned | ApiUpdatePinnedMessageIds | ApiUpdateChatMembers | ApiUpdateChatJoin | ApiUpdateChatLeave | ApiUpdateChatPinned | ApiUpdatePinnedMessageIds |
ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders | ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders |
ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdatePasskeyOption | ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdatePasskeyOption |
@ -907,7 +922,7 @@ export type ApiUpdate = (
ApiUpdateServiceNotification | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateServiceNotification | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus |
ApiUpdateUserFullInfo | ApiUpdateVideoProcessingPending | ApiUpdatePeerSettings | ApiUpdateUserAlreadyAuthorized | ApiUpdateUserFullInfo | ApiUpdateVideoProcessingPending | ApiUpdatePeerSettings | ApiUpdateUserAlreadyAuthorized |
ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateScheduledMessageSendFailed | ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateScheduledMessageSendFailed |
ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | ApiUpdateThreadReadState |
ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder | ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder |
ApiUpdateRecentStickers | ApiUpdateSavedGifs | ApiUpdateNewScheduledMessage | ApiUpdateMoveStickerSetToTop | ApiUpdateRecentStickers | ApiUpdateSavedGifs | ApiUpdateNewScheduledMessage | ApiUpdateMoveStickerSetToTop |
ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | ApiUpdateStarPaymentStateCompleted | ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | ApiUpdateStarPaymentStateCompleted |

View File

@ -1234,8 +1234,8 @@
"ChatListPinToTop" = "Pin to Top"; "ChatListPinToTop" = "Pin to Top";
"ChatListOpenInNewWindow" = "Open in New Window"; "ChatListOpenInNewWindow" = "Open in New Window";
"ChatListOpenInNewTab" = "Open in New Tab"; "ChatListOpenInNewTab" = "Open in New Tab";
"ChatListContextMaskAsRead" = "Mark as Read"; "ChatListContextMarkAsRead" = "Mark as Read";
"ChatListContextMaskAsUnread" = "Mark as Unread"; "ChatListContextMarkAsUnread" = "Mark as Unread";
"ChatListContextAddToFolder" = "Add to Folder"; "ChatListContextAddToFolder" = "Add to Folder";
"Unarchive" = "Unarchive"; "Unarchive" = "Unarchive";
"Archive" = "Archive"; "Archive" = "Archive";
@ -2558,6 +2558,7 @@
"BotReadTextFromClipboardTitle" = "Clipboard Access"; "BotReadTextFromClipboardTitle" = "Clipboard Access";
"BotReadTextFromClipboardDescription" = "{bot} wants to read the contents of your clipboard. Do you want to continue?"; "BotReadTextFromClipboardDescription" = "{bot} wants to read the contents of your clipboard. Do you want to continue?";
"BotReadTextFromClipboardConfirm" = "Allow"; "BotReadTextFromClipboardConfirm" = "Allow";
"ChatInfoForumTopic" = "Topic";
"DiceToast" = "Send a {emoji} emoji to try your luck."; "DiceToast" = "Send a {emoji} emoji to try your luck.";
"DiceToastSend" = "Send"; "DiceToastSend" = "Send";
"ChatTypePrivate" = "Private Chat"; "ChatTypePrivate" = "Private Chat";

View File

@ -1,6 +1,7 @@
import type { TeactNode } from '../../lib/teact/teact'; import type { TeactNode } from '../../lib/teact/teact';
import { import {
memo, memo,
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
@ -8,14 +9,19 @@ import {
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions } from '../../global'; 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 type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { getOrderedTopics } from '../../global/helpers'; import { getOrderedTopics } from '../../global/helpers';
import { selectTopic } from '../../global/selectors';
import { selectThread } from '../../global/selectors/threads';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { buildCollectionByCallback, mapTruthyValues } from '../../util/iteratees';
import { REM } from './helpers/mediaDimensions'; import { REM } from './helpers/mediaDimensions';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import { useShallowSelector } from '../../hooks/data/useSelector';
import { getIsMobile } from '../../hooks/useAppLayout'; import { getIsMobile } from '../../hooks/useAppLayout';
import { useFastClick } from '../../hooks/useFastClick'; import { useFastClick } from '../../hooks/useFastClick';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
@ -26,7 +32,7 @@ import styles from './ChatForumLastMessage.module.scss';
type OwnProps = { type OwnProps = {
chat: ApiChat; chat: ApiChat;
topics?: Record<number, ApiTopic>; topicIds?: number[];
hasTags?: boolean; hasTags?: boolean;
renderLastMessage: () => TeactNode | undefined; renderLastMessage: () => TeactNode | undefined;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
@ -37,7 +43,7 @@ const MAX_TOPICS = 3;
const ChatForumLastMessage = ({ const ChatForumLastMessage = ({
chat, chat,
topics, topicIds,
hasTags, hasTags,
renderLastMessage, renderLastMessage,
observeIntersection, observeIntersection,
@ -49,13 +55,29 @@ const ChatForumLastMessage = ({
const lang = useLang(); 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(() => { const [lastActiveTopic, ...otherTopics] = useMemo(() => {
if (!topics) { if (!topics) {
return []; return [];
} }
return getOrderedTopics(Object.values(topics), undefined, true).slice(0, MAX_TOPICS); const topicsThreadInfos = mapTruthyValues(topicsThreads, (t) => t?.threadInfo);
}, [topics]);
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 [isReversedCorner, setIsReversedCorner] = useState(false);
const [overwrittenWidth, setOverwrittenWidth] = useState<number | undefined>(undefined); const [overwrittenWidth, setOverwrittenWidth] = useState<number | undefined>(undefined);
@ -64,7 +86,8 @@ const ChatForumLastMessage = ({
handleClick: handleOpenTopicClick, handleClick: handleOpenTopicClick,
handleMouseDown: handleOpenTopicMouseDown, handleMouseDown: handleOpenTopicMouseDown,
} = useFastClick((e: React.MouseEvent<HTMLDivElement>) => { } = useFastClick((e: React.MouseEvent<HTMLDivElement>) => {
if (lastActiveTopic.unreadCount === 0 || (chat.isForumAsMessages && !chat.isBotForum)) return; if (!lastActiveTopic) return;
if (lastActiveTopicReadState?.unreadCount === 0 || (chat.isForumAsMessages && !chat.isBotForum)) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -111,7 +134,7 @@ const ChatForumLastMessage = ({
<div <div
className={buildClassName( className={buildClassName(
styles.mainColumn, styles.mainColumn,
lastActiveTopic.unreadCount && styles.unread, lastActiveTopicReadState?.unreadCount && styles.unread,
)} )}
ref={mainColumnRef} ref={mainColumnRef}
onClick={handleOpenTopicClick} onClick={handleOpenTopicClick}
@ -133,7 +156,7 @@ const ChatForumLastMessage = ({
{otherTopics.map((topic) => ( {otherTopics.map((topic) => (
<div <div
className={buildClassName( className={buildClassName(
styles.otherColumn, topic.unreadCount && styles.unread, styles.otherColumn, topicsThreads[topic.id]?.readState?.unreadCount && styles.unread,
)} )}
key={topic.id} key={topic.id}
> >
@ -159,7 +182,10 @@ const ChatForumLastMessage = ({
) )
} }
<div <div
className={buildClassName(styles.lastMessage, lastActiveTopic?.unreadCount && !hasTags && styles.unread)} className={buildClassName(
styles.lastMessage,
lastActiveTopicReadState?.unreadCount && !hasTags && styles.unread,
)}
ref={lastMessageRef} ref={lastMessageRef}
onClick={handleOpenTopicClick} onClick={handleOpenTopicClick}
onMouseDown={handleOpenTopicMouseDown} onMouseDown={handleOpenTopicMouseDown}

View File

@ -77,10 +77,7 @@ import {
selectChatType, selectChatType,
selectCurrentMessageList, selectCurrentMessageList,
selectCustomEmoji, selectCustomEmoji,
selectDraft,
selectEditingDraft,
selectEditingMessage, selectEditingMessage,
selectEditingScheduledDraft,
selectIsChatWithSelf, selectIsChatWithSelf,
selectIsCurrentUserFrozen, selectIsCurrentUserFrozen,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
@ -91,7 +88,6 @@ import {
selectNewestMessageWithBotKeyboardButtons, selectNewestMessageWithBotKeyboardButtons,
selectNotifyDefaults, selectNotifyDefaults,
selectNotifyException, selectNotifyException,
selectNoWebPage,
selectPeer, selectPeer,
selectPeerPaidMessagesStars, selectPeerPaidMessagesStars,
selectPeerStory, selectPeerStory,
@ -107,6 +103,12 @@ import {
} from '../../global/selectors'; } from '../../global/selectors';
import { selectCurrentLimit } from '../../global/selectors/limits'; import { selectCurrentLimit } from '../../global/selectors/limits';
import { selectSharedSettings } from '../../global/selectors/sharedState'; import { selectSharedSettings } from '../../global/selectors/sharedState';
import {
selectDraft,
selectEditingDraft,
selectEditingScheduledDraft,
selectNoWebPage,
} from '../../global/selectors/threads';
import { IS_IOS, IS_VOICE_RECORDING_SUPPORTED } from '../../util/browser/windowEnvironment'; import { IS_IOS, IS_VOICE_RECORDING_SUPPORTED } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates/dateFormat'; import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates/dateFormat';

View File

@ -19,10 +19,10 @@ import {
selectChatOnlineCount, selectChatOnlineCount,
selectIsChatRestricted, selectIsChatRestricted,
selectMonoforumChannel, selectMonoforumChannel,
selectThreadMessagesCount,
selectTopic, selectTopic,
selectUser, selectUser,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectThreadMessagesCount } from '../../global/selectors/threads';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { REM } from './helpers/mediaDimensions'; import { REM } from './helpers/mediaDimensions';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';

View File

@ -13,11 +13,11 @@ import {
} from '../../global/helpers'; } from '../../global/helpers';
import { import {
selectChatMessages, selectChatMessages,
selectThreadMessagesCount,
selectTopic, selectTopic,
selectUser, selectUser,
selectUserStatus, selectUserStatus,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectThreadMessagesCount } from '../../global/selectors/threads';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { REM } from './helpers/mediaDimensions'; import { REM } from './helpers/mediaDimensions';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
@ -214,8 +214,10 @@ const PrivateChatInfo = ({
className="message-count-transition" className="message-count-transition"
> >
{messagesCount !== undefined {messagesCount !== undefined
? lang('Messages', { count: messagesCount }, { pluralValue: messagesCount }) ? (messagesCount > 0
: lang('ChatInfoNoMessages')} ? lang('Messages', { count: messagesCount }, { pluralValue: messagesCount })
: lang('ChatInfoNoMessages')
) : lang('ChatInfoForumTopic')}
</Transition> </Transition>
</span> </span>
); );

View File

@ -20,9 +20,9 @@ import {
selectPeerPaidMessagesStars, selectPeerPaidMessagesStars,
selectShouldSchedule, selectShouldSchedule,
selectStickerSet, selectStickerSet,
selectThreadInfo,
selectTopic, selectTopic,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectThreadInfo } from '../../global/selectors/threads';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { copyTextToClipboard } from '../../util/clipboard'; import { copyTextToClipboard } from '../../util/clipboard';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';

View File

@ -4,6 +4,7 @@ import {
import type { ApiBusinessWorkHours } from '../../../api/types'; import type { ApiBusinessWorkHours } from '../../../api/types';
import { selectTimezones } from '../../../global/selectors';
import { import {
VTT_PROFILE_BUSINESS_HOURS, VTT_PROFILE_BUSINESS_HOURS,
VTT_PROFILE_BUSINESS_HOURS_COLLAPSE, VTT_PROFILE_BUSINESS_HOURS_COLLAPSE,
@ -18,7 +19,7 @@ import {
import { useViewTransition } from '../../../hooks/animations/useViewTransition'; import { useViewTransition } from '../../../hooks/animations/useViewTransition';
import { useVtn } from '../../../hooks/animations/useVtn'; 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 useInterval from '../../../hooks/schedulers/useInterval';
import useDerivedState from '../../../hooks/useDerivedState'; import useDerivedState from '../../../hooks/useDerivedState';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
@ -52,7 +53,7 @@ const BusinessHours = ({
useInterval(forceUpdate, 60 * 1000); useInterval(forceUpdate, 60 * 1000);
const timezoneSignal = useSelectorSignal((global) => global.timezones?.byId); const timezoneSignal = useSelectorSignal(selectTimezones);
const timezones = useDerivedState(timezoneSignal, [timezoneSignal]); const timezones = useDerivedState(timezoneSignal, [timezoneSignal]);
const timezoneMinuteDifference = useMemo(() => { const timezoneMinuteDifference = useMemo(() => {
if (!timezones) return 0; if (!timezones) return 0;

View File

@ -33,13 +33,13 @@ import {
selectPeerSavedGifts, selectPeerSavedGifts,
selectTabState, selectTabState,
selectTheme, selectTheme,
selectThreadMessagesCount,
selectTopic, selectTopic,
selectUser, selectUser,
selectUserFullInfo, selectUserFullInfo,
selectUserStatus, selectUserStatus,
} from '../../../global/selectors/index'; } from '../../../global/selectors/index';
import { selectSharedSettings } from '../../../global/selectors/sharedState'; import { selectSharedSettings } from '../../../global/selectors/sharedState';
import { selectThreadMessagesCount } from '../../../global/selectors/threads.ts';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment'; import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle'; import buildStyle from '../../../util/buildStyle';
@ -654,7 +654,7 @@ export default memo(withGlobal<OwnProps>(
emojiStatus, emojiStatus,
profilePhotos, profilePhotos,
topic, topic,
messagesCount: topic ? selectThreadMessagesCount(global, peerId, currentTopicId!) : undefined, messagesCount: topic ? selectThreadMessagesCount(global, peerId, topic.id) : undefined,
profileColorOption: profileColor, profileColorOption: profileColor,
theme, theme,
isPlain: !hasBackground, isPlain: !hasBackground,

View File

@ -3,10 +3,13 @@ import { getActions, getGlobal } from '../../../global';
import type { GlobalState } from '../../../global/types'; import type { GlobalState } from '../../../global/types';
import type { CustomPeer } from '../../../types'; import type { CustomPeer } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { ANIMATION_LEVEL_MIN, ARCHIVED_FOLDER_ID } from '../../../config'; import { ANIMATION_LEVEL_MIN, ARCHIVED_FOLDER_ID } from '../../../config';
import { getChatTitle } from '../../../global/helpers'; import { getChatTitle } from '../../../global/helpers';
import { selectChat } from '../../../global/selectors';
import { selectAnimationLevel } from '../../../global/selectors/sharedState'; import { selectAnimationLevel } from '../../../global/selectors/sharedState';
import { selectThreadReadState } from '../../../global/selectors/threads';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle'; import buildStyle from '../../../util/buildStyle';
import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners'; import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners';
@ -95,11 +98,12 @@ const Archive = ({
const previewItems = useMemo(() => { const previewItems = useMemo(() => {
if (!orderedChatIds?.length) return lang('Loading'); if (!orderedChatIds?.length) return lang('Loading');
const chatsById = getGlobal().chats.byId; const global = getGlobal();
return orderedChatIds.slice(0, PREVIEW_SLICE).map((chatId, i, arr) => { return orderedChatIds.slice(0, PREVIEW_SLICE).map((chatId, i, arr) => {
const isLast = i === arr.length - 1; 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) { if (!chat) {
return undefined; return undefined;
} }
@ -108,7 +112,7 @@ const Archive = ({
return ( return (
<> <>
<span className={buildClassName(styles.chat, archiveUnreadCount && chat.unreadCount && styles.unread)}> <span className={buildClassName(styles.chat, archiveUnreadCount && readState?.unreadCount && styles.unread)}>
{renderText(title)} {renderText(title)}
</span> </span>
{isLast ? '' : ', '} {isLast ? '' : ', '}

View File

@ -33,7 +33,6 @@ import {
selectChatLastMessageId, selectChatLastMessageId,
selectChatMessage, selectChatMessage,
selectCurrentMessageList, selectCurrentMessageList,
selectDraft,
selectIsCurrentUserFrozen, selectIsCurrentUserFrozen,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
selectIsForumPanelClosed, selectIsForumPanelClosed,
@ -46,19 +45,19 @@ import {
selectPeerStory, selectPeerStory,
selectSender, selectSender,
selectTabState, selectTabState,
selectThreadParam,
selectTopicFromMessage, selectTopicFromMessage,
selectTopicsInfo, selectTopicsInfo,
selectUser, selectUser,
selectUserStatus, selectUserStatus,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { selectDraft, selectThreadLocalStateParam } from '../../../global/selectors/threads';
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/browser/windowEnvironment'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { isUserId } from '../../../util/entities/ids'; import { isUserId } from '../../../util/entities/ids';
import { getChatFolderIds } from '../../../util/folderManager'; import { getChatFolderIds } from '../../../util/folderManager';
import { createLocationHash } from '../../../util/routing'; import { createLocationHash } from '../../../util/routing';
import useSelectorSignal from '../../../hooks/data/useSelectorSignal'; import { useSelectorSignal } from '../../../hooks/data/useSelector';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
import useChatContextActions from '../../../hooks/useChatContextActions'; import useChatContextActions from '../../../hooks/useChatContextActions';
import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useEnsureMessage from '../../../hooks/useEnsureMessage';
@ -96,11 +95,11 @@ type OwnProps = {
previewMessageId?: number; previewMessageId?: number;
className?: string; className?: string;
withTags?: boolean; withTags?: boolean;
isFoldersSidebarShown?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onDragEnter?: (chatId: string) => void; onDragEnter?: (chatId: string) => void;
onDragLeave?: NoneToVoidFunction; onDragLeave?: NoneToVoidFunction;
onReorderAnimationEnd?: NoneToVoidFunction; onReorderAnimationEnd?: NoneToVoidFunction;
isFoldersSidebarShown?: boolean;
}; };
type StateProps = { type StateProps = {
@ -108,7 +107,6 @@ type StateProps = {
monoforumChannel?: ApiChat; monoforumChannel?: ApiChat;
lastMessageStory?: ApiTypeStory; lastMessageStory?: ApiTypeStory;
listedTopicIds?: number[]; listedTopicIds?: number[];
topics?: Record<number, ApiTopic>;
isMuted?: boolean; isMuted?: boolean;
user?: ApiUser; user?: ApiUser;
userStatus?: ApiUserStatus; userStatus?: ApiUserStatus;
@ -142,7 +140,6 @@ const Chat: FC<OwnProps & StateProps> = ({
animationType, animationType,
isPinned, isPinned,
listedTopicIds, listedTopicIds,
topics,
observeIntersection, observeIntersection,
chat, chat,
monoforumChannel, monoforumChannel,
@ -170,16 +167,16 @@ const Chat: FC<OwnProps & StateProps> = ({
previewMessageId, previewMessageId,
className, className,
isSynced, isSynced,
onDragEnter,
onDragLeave,
isAccountFrozen, isAccountFrozen,
chatFolderIds, chatFolderIds,
orderedFolderIds, orderedFolderIds,
chatFoldersById, chatFoldersById,
areTagsEnabled, areTagsEnabled,
withTags, withTags,
onReorderAnimationEnd,
isFoldersSidebarShown, isFoldersSidebarShown,
onDragEnter,
onDragLeave,
onReorderAnimationEnd,
}) => { }) => {
const { const {
openChat, openChat,
@ -245,7 +242,7 @@ const Chat: FC<OwnProps & StateProps> = ({
isSavedDialog, isSavedDialog,
isPreview, isPreview,
onReorderAnimationEnd, onReorderAnimationEnd,
topics, topicIds: listedTopicIds,
hasTags: shouldRenderTags, hasTags: shouldRenderTags,
}); });
@ -366,7 +363,7 @@ const Chat: FC<OwnProps & StateProps> = ({
isSavedDialog, isSavedDialog,
currentUserId, currentUserId,
isPreview, isPreview,
topics, topicIds: listedTopicIds,
}); });
const isIntersecting = useIsIntersecting(ref, chat ? observeIntersection : undefined); const isIntersecting = useIsIntersecting(ref, chat ? observeIntersection : undefined);
@ -445,7 +442,6 @@ const Chat: FC<OwnProps & StateProps> = ({
isMuted={isMuted} isMuted={isMuted}
shouldShowOnlyMostImportant shouldShowOnlyMostImportant
forceHidden={getIsForumPanelClosed} forceHidden={getIsForumPanelClosed}
topics={topics}
isSelected={isSelected} isSelected={isSelected}
isOnAvatar isOnAvatar
/> />
@ -485,7 +481,6 @@ const Chat: FC<OwnProps & StateProps> = ({
isMuted={isMuted} isMuted={isMuted}
isSavedDialog={isSavedDialog} isSavedDialog={isSavedDialog}
hasMiniApp={user?.hasMainMiniApp} hasMiniApp={user?.hasMainMiniApp}
topics={topics}
isSelected={isSelected} isSelected={isSelected}
transitionClassName="chat-badge-transition" transitionClassName="chat-badge-transition"
/> />
@ -557,7 +552,7 @@ export default memo(withGlobal<OwnProps>(
const { const {
chatId: currentChatId, chatId: currentChatId,
threadId: currentThreadId, threadId: currentThreadId,
type: messageListType, type: currentMessageListType,
} = selectCurrentMessageList(global) || {}; } = selectCurrentMessageList(global) || {};
const isSelected = !isPreview && chatId === currentChatId && (isSavedDialog const isSelected = !isPreview && chatId === currentChatId && (isSavedDialog
? chatId === currentThreadId : currentThreadId === MAIN_THREAD_ID); ? chatId === currentThreadId : currentThreadId === MAIN_THREAD_ID);
@ -567,7 +562,7 @@ export default memo(withGlobal<OwnProps>(
const userStatus = selectUserStatus(global, chatId); const userStatus = selectUserStatus(global, chatId);
const lastMessageTopic = lastMessage && selectTopicFromMessage(global, lastMessage); 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); const topicsInfo = selectTopicsInfo(global, chatId);
@ -585,9 +580,11 @@ export default memo(withGlobal<OwnProps>(
isSelected, isSelected,
isSelectedForum, isSelectedForum,
isForumPanelOpen: selectIsForumPanelOpen(global), isForumPanelOpen: selectIsForumPanelOpen(global),
canScrollDown: isSelected && messageListType === 'thread', canScrollDown: isSelected && currentMessageListType === 'thread',
canChangeFolder: (global.chatFolders.orderedIds?.length || 0) > 1, 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, user,
userStatus, userStatus,
lastMessageTopic, lastMessageTopic,
@ -597,7 +594,6 @@ export default memo(withGlobal<OwnProps>(
lastMessageId, lastMessageId,
currentUserId: global.currentUserId!, currentUserId: global.currentUserId!,
listedTopicIds: topicsInfo?.listedTopicIds, listedTopicIds: topicsInfo?.listedTopicIds,
topics: topicsInfo?.topicsById,
isSynced: global.isSynced, isSynced: global.isSynced,
lastMessageStory, lastMessageStory,
isAccountFrozen, isAccountFrozen,

View File

@ -1,15 +1,20 @@
import { memo, useMemo } from '../../../lib/teact/teact'; import { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; 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 { 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 buildClassName from '../../../util/buildClassName';
import { buildCollectionByCallback } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { isSignal } from '../../../util/signals'; import { isSignal } from '../../../util/signals';
import { formatIntegerCompact } from '../../../util/textFormat'; import { formatIntegerCompact } from '../../../util/textFormat';
import { extractCurrentThemeParams } from '../../../util/themeStyle'; import { extractCurrentThemeParams } from '../../../util/themeStyle';
import useSelector, { useShallowSelector } from '../../../hooks/data/useSelector';
import useDerivedState from '../../../hooks/useDerivedState'; import useDerivedState from '../../../hooks/useDerivedState';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
@ -31,7 +36,6 @@ type OwnProps = {
shouldShowOnlyMostImportant?: boolean; shouldShowOnlyMostImportant?: boolean;
hasMiniApp?: boolean; hasMiniApp?: boolean;
forceHidden?: boolean | Signal<boolean>; forceHidden?: boolean | Signal<boolean>;
topics?: Record<number, ApiTopic>;
isSelected?: boolean; isSelected?: boolean;
isOnAvatar?: boolean; isOnAvatar?: boolean;
transitionClassName?: string; transitionClassName?: string;
@ -40,7 +44,6 @@ type OwnProps = {
const ChatBadge = ({ const ChatBadge = ({
topic, topic,
topics,
chat, chat,
isPinned, isPinned,
isMuted, isMuted,
@ -58,23 +61,47 @@ const ChatBadge = ({
const lang = useLang(); 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 { const {
unreadMentionsCount = 0, unreadReactionsCount = 0, unreadMentionsCount: stateUnreadMentionsCount = 0,
} = !chat.isForum ? chat : {}; // TODO[forums] Unread mentions and reactions temporarily disabled for forums 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 isTopicUnopened = !isPinned && topic && !wasTopicOpened;
const isForum = chat.isForum && !topic; const isForum = chat.isForum && !topic;
const topicsWithUnread = useMemo(() => ( const topicsWithUnreadIds = useMemo(() => (
isForum && topics ? Object.values(topics).filter(({ unreadCount }) => unreadCount) : undefined isForum && listedTopicIds ? listedTopicIds.filter((tId) => topicsReadStates[tId]?.unreadCount) : undefined
), [topics, isForum]); ), [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(() => { const unreadCount = isForum ? topicsWithUnreadIds?.length : stateUnreadCount;
if (!isForum) { const unreadMentionsCount = isForum ? topicsWithUnreadMentionsIds?.length : stateUnreadMentionsCount;
return (topic || chat).unreadCount; const unreadReactionsCount = isForum ? topicsWithUnreadReactionsIds?.length : stateUnreadReactionsCount;
}
return topicsWithUnread?.length;
}, [chat, topic, topicsWithUnread, isForum]);
const shouldBeUnMuted = useMemo(() => { const shouldBeUnMuted = useMemo(() => {
if (!isForum) { if (!isForum) {
@ -82,17 +109,17 @@ const ChatBadge = ({
} }
if (isMuted) { if (isMuted) {
return topicsWithUnread?.some((acc) => acc.notifySettings.mutedUntil === 0); return topicsWithUnreadIds?.some((tId) => topicsById?.[tId]?.notifySettings.mutedUntil === 0);
} }
const isEveryUnreadMuted = topicsWithUnread?.every((acc) => ( const isEveryUnreadMuted = topicsWithUnreadIds?.every((tId) => {
acc.notifySettings.mutedUntil && acc.notifySettings.mutedUntil > getServerTime() const mutedUntil = topicsById?.[tId]?.notifySettings.mutedUntil;
)); return mutedUntil && mutedUntil > getServerTime();
});
return !isEveryUnreadMuted; 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 isUnread = Boolean((unreadCount || hasUnreadMark) && !isSavedDialog);
const resolvedForceHidden = useDerivedState( const resolvedForceHidden = useDerivedState(

View File

@ -1,10 +1,11 @@
import { import {
beginHeavyAnimation, beginHeavyAnimation,
memo, useEffect, useMemo, useRef, useState, memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../../lib/teact/teact'; } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global'; import { getActions, withGlobal } from '../../../../global';
import type { ApiChat } from '../../../../api/types'; import type { ApiChat } from '../../../../api/types';
import type { GlobalState } from '../../../../global/types';
import type { TopicsInfo } from '../../../../types'; import type { TopicsInfo } from '../../../../types';
import { MAIN_THREAD_ID } from '../../../../api/types'; import { MAIN_THREAD_ID } from '../../../../api/types';
@ -21,13 +22,16 @@ import {
selectTabState, selectTabState,
selectTopicsInfo, selectTopicsInfo,
} from '../../../../global/selectors'; } from '../../../../global/selectors';
import { selectThread } from '../../../../global/selectors/threads';
import { IS_TOUCH_ENV } from '../../../../util/browser/windowEnvironment'; import { IS_TOUCH_ENV } from '../../../../util/browser/windowEnvironment';
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import captureEscKeyListener from '../../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../../util/captureEscKeyListener';
import { captureEvents, SwipeDirection } from '../../../../util/captureEvents'; import { captureEvents, SwipeDirection } from '../../../../util/captureEvents';
import { waitForTransitionEnd } from '../../../../util/cssAnimationEndListeners'; import { waitForTransitionEnd } from '../../../../util/cssAnimationEndListeners';
import { isUserId } from '../../../../util/entities/ids'; import { isUserId } from '../../../../util/entities/ids';
import { mapTruthyValues, mapValues } from '../../../../util/iteratees';
import useSelector from '../../../../hooks/data/useSelector';
import useAppLayout from '../../../../hooks/useAppLayout'; import useAppLayout from '../../../../hooks/useAppLayout';
import useHistoryBack from '../../../../hooks/useHistoryBack'; import useHistoryBack from '../../../../hooks/useHistoryBack';
import useInfiniteScroll from '../../../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../../../hooks/useInfiniteScroll';
@ -122,10 +126,18 @@ const ForumPanel = ({
setIsScrolled(!isIntersecting); 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 orderedIds = useMemo(() => {
const ids = topicsInfo const topicsThreadInfos = topicsThreads && mapValues(topicsThreads, (t) => t.threadInfo);
const ids = topicsInfo && topicsThreads
? getOrderedTopics( ? getOrderedTopics(
Object.values(topicsInfo.topicsById), Object.values(topicsInfo.topicsById),
topicsThreadInfos,
topicsInfo.orderedPinnedTopicIds, topicsInfo.orderedPinnedTopicIds,
).map(({ id }) => id) ).map(({ id }) => id)
: []; : [];
@ -133,7 +145,7 @@ const ForumPanel = ({
if (!chat?.isBotForum) return ids; if (!chat?.isBotForum) return ids;
return [MAIN_THREAD_ID, ...ids]; return [MAIN_THREAD_ID, ...ids];
}, [chat?.isBotForum, topicsInfo]); }, [chat?.isBotForum, topicsInfo, topicsThreads]);
const { orderDiffById, shiftDiff, getAnimationType, onReorderAnimationEnd } = useOrderDiff(orderedIds, 0, chat?.id); const { orderDiffById, shiftDiff, getAnimationType, onReorderAnimationEnd } = useOrderDiff(orderedIds, 0, chat?.id);

View File

@ -17,16 +17,19 @@ import {
selectChat, selectChat,
selectChatMessage, selectChatMessage,
selectCurrentMessageList, selectCurrentMessageList,
selectDraft,
selectNotifyDefaults, selectNotifyDefaults,
selectNotifyException, selectNotifyException,
selectOutgoingStatus, selectOutgoingStatus,
selectPeerStory, selectPeerStory,
selectSender, selectSender,
selectThreadInfo, selectTopicsInfo,
selectThreadParam,
selectTopics,
} from '../../../../global/selectors'; } from '../../../../global/selectors';
import {
selectDraft,
selectThreadInfo,
selectThreadLocalStateParam,
selectThreadReadState,
} from '../../../../global/selectors/threads';
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment';
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import { createLocationHash } from '../../../../util/routing'; import { createLocationHash } from '../../../../util/routing';
@ -73,7 +76,8 @@ type StateProps = {
canScrollDown?: boolean; canScrollDown?: boolean;
wasTopicOpened?: boolean; wasTopicOpened?: boolean;
withInterfaceAnimations?: boolean; withInterfaceAnimations?: boolean;
topics?: Record<number, ApiTopic>; topicIds?: number[];
unreadCount?: number;
}; };
const Topic = ({ const Topic = ({
@ -97,7 +101,8 @@ const Topic = ({
typingStatus, typingStatus,
draft, draft,
wasTopicOpened, wasTopicOpened,
topics, topicIds,
unreadCount,
onReorderAnimationEnd, onReorderAnimationEnd,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
@ -149,7 +154,7 @@ const Topic = ({
observeIntersection, observeIntersection,
isTopic: true, isTopic: true,
typingStatus, typingStatus,
topics, topicIds,
statefulMediaContent: groupStatefulContent({ story: lastMessageStory }), statefulMediaContent: groupStatefulContent({ story: lastMessageStory }),
animationType, animationType,
@ -180,6 +185,7 @@ const Topic = ({
isChatMuted, isChatMuted,
wasOpened: wasTopicOpened, wasOpened: wasTopicOpened,
canDelete, canDelete,
unreadCount,
handleDelete: handleOpenDeleteModal, handleDelete: handleOpenDeleteModal,
handleMute, handleMute,
handleUnmute, handleUnmute,
@ -226,7 +232,6 @@ const Topic = ({
isMuted={isMuted} isMuted={isMuted}
topic={topic} topic={topic}
wasTopicOpened={wasTopicOpened} wasTopicOpened={wasTopicOpened}
topics={topics}
isSelected={isSelected} isSelected={isSelected}
/> />
</div> </div>
@ -259,14 +264,17 @@ export default memo(withGlobal<OwnProps>(
(global, { chatId, topic, isSelected }) => { (global, { chatId, topic, isSelected }) => {
const chat = selectChat(global, chatId); 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 { isOutgoing } = lastMessage || {};
const lastMessageSender = lastMessage && selectSender(global, 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 draft = selectDraft(global, chatId, topic.id);
const threadInfo = selectThreadInfo(global, chatId, topic.id);
const wasTopicOpened = chat?.isBotForum || Boolean(threadInfo?.lastReadInboxMessageId); const readState = selectThreadReadState(global, chatId, topic.id);
const topics = selectTopics(global, chatId); const wasTopicOpened = chat?.isBotForum || Boolean(readState?.lastReadInboxMessageId);
const topicIds = selectTopicsInfo(global, chatId)?.listedTopicIds;
const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {};
@ -287,12 +295,13 @@ export default memo(withGlobal<OwnProps>(
withInterfaceAnimations: selectCanAnimateInterface(global), withInterfaceAnimations: selectCanAnimateInterface(global),
draft, draft,
...(isOutgoing && lastMessage && { ...(isOutgoing && lastMessage && {
lastMessageOutgoingStatus: selectOutgoingStatus(global, lastMessage), lastMessageOutgoingStatus: selectOutgoingStatus(global, chatId, topic.id, lastMessage.id, 'thread'),
}), }),
canScrollDown: isSelected && chat?.id === currentChatId && currentThreadId === topic.id, canScrollDown: isSelected && chat?.id === currentChatId && currentThreadId === topic.id,
wasTopicOpened, wasTopicOpened,
topics, topicIds,
lastMessageStory, lastMessageStory,
unreadCount: readState?.unreadCount,
}; };
}, },
)(Topic)); )(Topic));

View File

@ -36,7 +36,7 @@ import TypingStatus from '../../../common/TypingStatus';
export default function useChatListEntry({ export default function useChatListEntry({
chat, chat,
topics, topicIds,
lastMessage, lastMessage,
statefulMediaContent, statefulMediaContent,
chatId, chatId,
@ -56,7 +56,7 @@ export default function useChatListEntry({
onReorderAnimationEnd, onReorderAnimationEnd,
}: { }: {
chat?: ApiChat; chat?: ApiChat;
topics?: Record<number, ApiTopic>; topicIds?: number[];
lastMessage?: ApiMessage; lastMessage?: ApiMessage;
statefulMediaContent: StatefulMediaContent | undefined; statefulMediaContent: StatefulMediaContent | undefined;
chatId: string; chatId: string;
@ -157,7 +157,7 @@ export default function useChatListEntry({
chat={chat} chat={chat}
renderLastMessage={renderLastMessageOrTyping} renderLastMessage={renderLastMessageOrTyping}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
topics={topics} topicIds={topicIds}
hasTags={hasTags} hasTags={hasTags}
/> />
); );

View File

@ -1,4 +1,4 @@
import { useMemo } from '../../../../lib/teact/teact'; import { useMemo } from '@teact';
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import type { ApiChat, ApiTopic } from '../../../../api/types'; import type { ApiChat, ApiTopic } from '../../../../api/types';
@ -14,6 +14,7 @@ import useOldLang from '../../../../hooks/useOldLang';
export default function useTopicContextActions({ export default function useTopicContextActions({
topic, topic,
unreadCount,
chat, chat,
isChatMuted, isChatMuted,
wasOpened, wasOpened,
@ -24,6 +25,7 @@ export default function useTopicContextActions({
}: { }: {
topic: ApiTopic; topic: ApiTopic;
chat: ApiChat; chat: ApiChat;
unreadCount?: number;
isChatMuted?: boolean; isChatMuted?: boolean;
wasOpened?: boolean; wasOpened?: boolean;
canDelete?: boolean; canDelete?: boolean;
@ -34,7 +36,7 @@ export default function useTopicContextActions({
const lang = useLang(); const lang = useLang();
const oldLang = useOldLang(); const oldLang = useOldLang();
return useMemo(() => { const preparedActions = useMemo(() => {
const { const {
isPinned, notifySettings, isClosed, id: topicId, isPinned, notifySettings, isClosed, id: topicId,
} = topic; } = topic;
@ -68,7 +70,7 @@ export default function useTopicContextActions({
}, },
}; };
const actionUnreadMark = topic.unreadCount || !wasOpened const actionUnreadMark = unreadCount || !wasOpened
? { ? {
title: oldLang('MarkAsRead'), title: oldLang('MarkAsRead'),
icon: 'readchats', icon: 'readchats',
@ -130,5 +132,10 @@ export default function useTopicContextActions({
actionCloseTopic, actionCloseTopic,
actionDelete, actionDelete,
]) as MenuItemContextAction[]; ]) 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;
} }

View File

@ -1,4 +1,3 @@
import type { FC } from '@teact';
import { memo, useCallback } from '@teact'; import { memo, useCallback } from '@teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
@ -12,6 +11,7 @@ import {
selectIsChatPinned, selectIsChatPinned,
selectNotifyDefaults, selectNotifyDefaults,
selectNotifyException, selectNotifyException,
selectTopicsInfo,
selectUser, selectUser,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { onDragEnter, onDragLeave } from '../../../util/dragNDropHandlers.ts'; import { onDragEnter, onDragLeave } from '../../../util/dragNDropHandlers.ts';
@ -41,22 +41,24 @@ type OwnProps = {
type StateProps = { type StateProps = {
chat?: ApiChat; chat?: ApiChat;
user?: ApiUser; user?: ApiUser;
listedTopicIds?: number[];
isPinned?: boolean; isPinned?: boolean;
isMuted?: boolean; isMuted?: boolean;
canChangeFolder?: boolean; canChangeFolder?: boolean;
}; };
const LeftSearchResultChat: FC<OwnProps & StateProps> = ({ const LeftSearchResultChat = ({
chatId, chatId,
withUsername, withUsername,
chat, chat,
user, user,
listedTopicIds,
isPinned, isPinned,
isMuted, isMuted,
canChangeFolder, canChangeFolder,
withOpenAppButton, withOpenAppButton,
onClick, onClick,
}) => { }: OwnProps & StateProps) => {
const { requestMainWebView, updateChatMutedState, openQuickPreview } = getActions(); const { requestMainWebView, updateChatMutedState, openQuickPreview } = getActions();
const lang = useLang(); const lang = useLang();
@ -85,6 +87,7 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
isPinned, isPinned,
isMuted, isMuted,
canChangeFolder, canChangeFolder,
topicIds: listedTopicIds,
handleMute, handleMute,
handleUnmute, handleUnmute,
handleChatFolderChange, handleChatFolderChange,
@ -186,12 +189,15 @@ export default memo(withGlobal<OwnProps>(
const isPinned = selectIsChatPinned(global, chatId); const isPinned = selectIsChatPinned(global, chatId);
const isMuted = chat && getIsChatMuted(chat, selectNotifyDefaults(global), selectNotifyException(global, chat.id)); const isMuted = chat && getIsChatMuted(chat, selectNotifyDefaults(global), selectNotifyException(global, chat.id));
const listedTopicIds = selectTopicsInfo(global, chatId)?.listedTopicIds;
return { return {
chat, chat,
user, user,
isPinned, isPinned,
isMuted, isMuted,
canChangeFolder: Boolean(global.chatFolders.orderedIds?.length), canChangeFolder: Boolean(global.chatFolders.orderedIds?.length),
listedTopicIds,
}; };
}, },
)(LeftSearchResultChat)); )(LeftSearchResultChat));

View File

@ -4,19 +4,21 @@ import {
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type {
ApiBusinessIntro, ApiSticker, ApiUpdateConnectionStateType, ApiUser,
} from '../../api/types';
import type { MessageList } from '../../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 { getUserFullName } from '../../global/helpers';
import { import {
selectChat, selectChat,
selectChatLastMessage, selectChatLastMessageId,
selectCurrentMessageList, selectCurrentMessageList,
selectUser, selectUser,
selectUserFullInfo, selectUserFullInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectThreadReadState } from '../../global/selectors/threads';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
@ -31,7 +33,7 @@ type OwnProps = {
type StateProps = { type StateProps = {
defaultStickers?: ApiSticker[]; defaultStickers?: ApiSticker[];
lastUnreadMessageId?: number; lastMessageId?: number;
connectionState?: ApiUpdateConnectionStateType; connectionState?: ApiUpdateConnectionStateType;
currentMessageList?: MessageList; currentMessageList?: MessageList;
businessIntro?: ApiBusinessIntro; businessIntro?: ApiBusinessIntro;
@ -41,7 +43,7 @@ type StateProps = {
const ContactGreeting: FC<OwnProps & StateProps> = ({ const ContactGreeting: FC<OwnProps & StateProps> = ({
defaultStickers, defaultStickers,
connectionState, connectionState,
lastUnreadMessageId, lastMessageId,
currentMessageList, currentMessageList,
businessIntro, businessIntro,
user, user,
@ -52,7 +54,7 @@ const ContactGreeting: FC<OwnProps & StateProps> = ({
markMessageListRead, markMessageListRead,
} = getActions(); } = getActions();
const lang = useOldLang(); const oldLang = useOldLang();
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
@ -73,10 +75,10 @@ const ContactGreeting: FC<OwnProps & StateProps> = ({
}, [connectionState, loadGreetingStickers, defaultStickers]); }, [connectionState, loadGreetingStickers, defaultStickers]);
useEffect(() => { useEffect(() => {
if (connectionState === 'connectionStateReady' && lastUnreadMessageId) { if (connectionState === 'connectionStateReady' && lastMessageId) {
markMessageListRead({ maxId: lastUnreadMessageId }); markMessageListRead({ maxId: lastMessageId });
} }
}, [connectionState, markMessageListRead, lastUnreadMessageId]); }, [connectionState, lastMessageId]);
const handleStickerSelect = useLastCallback(() => { const handleStickerSelect = useLastCallback(() => {
if (!currentMessageList) { if (!currentMessageList) {
@ -92,8 +94,8 @@ const ContactGreeting: FC<OwnProps & StateProps> = ({
}); });
}); });
const title = businessIntro?.title || lang('Conversation.EmptyPlaceholder'); const title = businessIntro?.title || oldLang('Conversation.EmptyPlaceholder');
const description = businessIntro?.description || lang('Conversation.GreetingText'); const description = businessIntro?.description || oldLang('Conversation.GreetingText');
return ( return (
<div className={styles.root}> <div className={styles.root}>
@ -114,7 +116,7 @@ const ContactGreeting: FC<OwnProps & StateProps> = ({
</div> </div>
{businessIntro && ( {businessIntro && (
<div className={styles.explainer}> <div className={styles.explainer}>
{lang('Chat.EmptyStateIntroFooter', getUserFullName(user))} {oldLang('Chat.EmptyStateIntroFooter', getUserFullName(user))}
</div> </div>
)} )}
</div> </div>
@ -131,14 +133,16 @@ export default memo(withGlobal<OwnProps>(
const user = selectUser(global, userId); const user = selectUser(global, userId);
const fullInfo = selectUserFullInfo(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 { return {
defaultStickers: stickers, defaultStickers: stickers,
lastUnreadMessageId: lastMessage && lastMessage.id !== chat.lastReadInboxMessageId lastMessageId: unreadCount ? lastMessageId : undefined,
? lastMessage.id
: undefined,
connectionState: global.connectionState, connectionState: global.connectionState,
currentMessageList: selectCurrentMessageList(global), currentMessageList: selectCurrentMessageList(global),
businessIntro: fullInfo?.businessIntro, businessIntro: fullInfo?.businessIntro,

View File

@ -1,11 +1,10 @@
import type { FC } from '../../lib/teact/teact';
import { memo, useEffect, useRef } from '../../lib/teact/teact'; import { memo, useEffect, useRef } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { MessageListType, ThreadId } from '../../types'; import type { MessageListType, ThreadId, ThreadReadState } from '../../types';
import { MAIN_THREAD_ID } from '../../api/types';
import { selectChat, selectCurrentMessageList, selectCurrentMiddleSearch } from '../../global/selectors'; import { selectChat, selectCurrentMessageList, selectCurrentMiddleSearch } from '../../global/selectors';
import { selectThreadReadState } from '../../global/selectors/threads';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
@ -24,35 +23,33 @@ type StateProps = {
chatId?: string; chatId?: string;
messageListType?: MessageListType; messageListType?: MessageListType;
threadId?: ThreadId; threadId?: ThreadId;
unreadCount?: number; threadReadState?: ThreadReadState;
unreadReactions?: number[]; shouldShowCount?: boolean;
unreadMentions?: number[];
reactionsCount?: number;
mentionsCount?: number;
}; };
const FloatingActionButtons: FC<OwnProps & StateProps> = ({ const FloatingActionButtons = ({
withScrollDown, withScrollDown,
canPost, canPost,
messageListType, messageListType,
chatId, chatId,
threadId, threadId,
unreadCount,
unreadReactions,
unreadMentions,
reactionsCount,
mentionsCount,
withExtraShift, withExtraShift,
}) => { threadReadState,
shouldShowCount,
}: OwnProps & StateProps) => {
const { const {
focusNextReply, focusNextReaction, focusNextMention, fetchUnreadReactions, focusNextReply, focusNextReaction, focusNextMention, loadUnreadReactions,
readAllMentions, readAllReactions, fetchUnreadMentions, scrollMessageListToBottom, readAllMentions, readAllReactions, loadUnreadMentions, scrollMessageListToBottom,
} = getActions(); } = getActions();
const elementRef = useRef<HTMLDivElement>(); const elementRef = useRef<HTMLDivElement>();
const hasUnreadReactions = Boolean(reactionsCount); const {
const hasUnreadMentions = Boolean(mentionsCount); unreadReactionsCount, unreadMentionsCount, unreadCount, unreadReactions, unreadMentions,
} = (shouldShowCount && threadReadState) || {};
const hasUnreadReactions = Boolean(unreadReactionsCount);
const hasUnreadMentions = Boolean(unreadMentionsCount);
const handleReadAllReactions = useLastCallback(() => { const handleReadAllReactions = useLastCallback(() => {
if (!chatId) return; if (!chatId) return;
@ -66,27 +63,15 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
useEffect(() => { useEffect(() => {
if (hasUnreadReactions && chatId && !unreadReactions?.length) { if (hasUnreadReactions && chatId && !unreadReactions?.length) {
fetchUnreadReactions({ chatId }); loadUnreadReactions({ chatId, threadId });
} }
}, [chatId, fetchUnreadReactions, hasUnreadReactions, unreadReactions?.length]); }, [chatId, threadId, hasUnreadReactions, unreadReactions?.length]);
useEffect(() => {
if (hasUnreadReactions && chatId) {
fetchUnreadReactions({ chatId });
}
}, [chatId, fetchUnreadReactions, hasUnreadReactions]);
useEffect(() => { useEffect(() => {
if (hasUnreadMentions && chatId && !unreadMentions?.length) { if (hasUnreadMentions && chatId && !unreadMentions?.length) {
fetchUnreadMentions({ chatId }); loadUnreadMentions({ chatId, threadId });
} }
}, [chatId, fetchUnreadMentions, hasUnreadMentions, unreadMentions?.length]); }, [chatId, threadId, hasUnreadMentions, unreadMentions?.length]);
useEffect(() => {
if (hasUnreadMentions && chatId) {
fetchUnreadMentions({ chatId });
}
}, [chatId, fetchUnreadMentions, hasUnreadMentions]);
const handleScrollDownClick = useLastCallback(() => { const handleScrollDownClick = useLastCallback(() => {
if (!withScrollDown) { if (!withScrollDown) {
@ -100,10 +85,20 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
} }
}); });
const handleFocusNextReaction = useLastCallback(() => {
if (!chatId) return;
focusNextReaction({ chatId, threadId });
});
const handleFocusNextMention = useLastCallback(() => {
if (!chatId) return;
focusNextMention({ chatId, threadId });
});
const fabClassName = buildClassName( const fabClassName = buildClassName(
styles.root, styles.root,
(withScrollDown || Boolean(reactionsCount) || Boolean(mentionsCount)) && styles.revealed, (withScrollDown || hasUnreadReactions || hasUnreadMentions) && styles.revealed,
(Boolean(reactionsCount) || Boolean(mentionsCount)) && !withScrollDown && styles.hideScrollDown, (hasUnreadReactions || hasUnreadMentions) && !withScrollDown && styles.hideScrollDown,
!canPost && styles.noComposer, !canPost && styles.noComposer,
!withExtraShift && styles.noExtraShift, !withExtraShift && styles.noExtraShift,
); );
@ -113,9 +108,9 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
<ScrollDownButton <ScrollDownButton
icon="heart-outline" icon="heart-outline"
ariaLabelLang="AccDescrReactionMentionDown" ariaLabelLang="AccDescrReactionMentionDown"
onClick={focusNextReaction} onClick={handleFocusNextReaction}
onReadAll={handleReadAllReactions} onReadAll={handleReadAllReactions}
unreadCount={reactionsCount} unreadCount={unreadReactionsCount}
className={buildClassName( className={buildClassName(
styles.reactions, styles.reactions,
!hasUnreadReactions && styles.hidden, !hasUnreadReactions && styles.hidden,
@ -126,9 +121,9 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
<ScrollDownButton <ScrollDownButton
icon="mention" icon="mention"
ariaLabelLang="AccDescrMentionDown" ariaLabelLang="AccDescrMentionDown"
onClick={focusNextMention} onClick={handleFocusNextMention}
onReadAll={handleReadAllMentions} onReadAll={handleReadAllMentions}
unreadCount={mentionsCount} unreadCount={unreadMentionsCount}
className={!hasUnreadMentions && styles.hidden} className={!hasUnreadMentions && styles.hidden}
/> />
@ -154,18 +149,14 @@ export default memo(withGlobal<OwnProps>(
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const hasActiveMiddleSearch = Boolean(selectCurrentMiddleSearch(global)); const hasActiveMiddleSearch = Boolean(selectCurrentMiddleSearch(global));
const shouldShowCount = chat && threadId === MAIN_THREAD_ID && messageListType === 'thread' const shouldShowCount = chat && messageListType === 'thread' && !hasActiveMiddleSearch;
&& !hasActiveMiddleSearch;
return { return {
messageListType, messageListType,
chatId, chatId,
threadId, threadId,
reactionsCount: shouldShowCount ? chat.unreadReactionsCount : undefined, threadReadState: selectThreadReadState(global, chatId, threadId),
unreadReactions: shouldShowCount ? chat.unreadReactions : undefined, shouldShowCount,
unreadMentions: shouldShowCount ? chat.unreadMentions : undefined,
mentionsCount: shouldShowCount ? chat.unreadMentionsCount : undefined,
unreadCount: shouldShowCount ? chat.unreadCount : undefined,
}; };
}, },
)(FloatingActionButtons)); )(FloatingActionButtons));

View File

@ -40,18 +40,21 @@ import {
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
selectIsInSelectMode, selectIsInSelectMode,
selectIsViewportNewest, selectIsViewportNewest,
selectLastScrollOffset,
selectMonoforumChannel, selectMonoforumChannel,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectScrollOffset,
selectTabState, selectTabState,
selectThreadInfo,
selectTopic, selectTopic,
selectTranslationLanguage, selectTranslationLanguage,
selectUserFullInfo, selectUserFullInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectIsChatRestricted } from '../../global/selectors/chats'; import { selectIsChatRestricted } from '../../global/selectors/chats';
import { selectActiveRestrictionReasons, selectCurrentMessageList } from '../../global/selectors/messages'; import { selectActiveRestrictionReasons, selectCurrentMessageList } from '../../global/selectors/messages';
import {
selectLastScrollOffset,
selectScrollOffset,
selectThreadInfo,
selectThreadReadState,
} from '../../global/selectors/threads';
import animateScroll, { isAnimatingScroll, restartCurrentScrollAnimation } from '../../util/animateScroll'; import animateScroll, { isAnimatingScroll, restartCurrentScrollAnimation } from '../../util/animateScroll';
import { IS_FIREFOX } from '../../util/browser/windowEnvironment'; import { IS_FIREFOX } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -907,6 +910,7 @@ export default memo(withGlobal<OwnProps>(
const currentUserId = global.currentUserId!; const currentUserId = global.currentUserId!;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const userFullInfo = selectUserFullInfo(global, chatId); const userFullInfo = selectUserFullInfo(global, chatId);
const readState = selectThreadReadState(global, chatId, threadId);
if (!chat) { if (!chat) {
return { currentUserId } as Complete<StateProps>; return { currentUserId } as Complete<StateProps>;
} }
@ -932,7 +936,7 @@ export default memo(withGlobal<OwnProps>(
const withLastMessageWhenPreloading = ( const withLastMessageWhenPreloading = (
threadId === MAIN_THREAD_ID threadId === MAIN_THREAD_ID
&& !messageIds && !chat.unreadCount && !focusingId && lastMessage && !lastMessage.groupedId && !messageIds && readState && !readState.unreadCount && !focusingId && lastMessage && !lastMessage.groupedId
); );
const chatBot = selectBot(global, chatId); const chatBot = selectBot(global, chatId);

View File

@ -37,8 +37,6 @@ import {
selectChatFullInfo, selectChatFullInfo,
selectCurrentMessageList, selectCurrentMessageList,
selectCurrentMiddleSearch, selectCurrentMiddleSearch,
selectDraft,
selectEditingId,
selectIsChatBotNotStarted, selectIsChatBotNotStarted,
selectIsCurrentUserFrozen, selectIsCurrentUserFrozen,
selectIsInSelectMode, selectIsInSelectMode,
@ -50,12 +48,12 @@ import {
selectTabState, selectTabState,
selectTheme, selectTheme,
selectThemeValues, selectThemeValues,
selectThreadInfo,
selectTopic, selectTopic,
selectTopics, selectTopics,
selectUserFullInfo, selectUserFullInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectSharedSettings } from '../../global/selectors/sharedState'; import { selectSharedSettings } from '../../global/selectors/sharedState';
import { selectDraft, selectEditingId, selectThreadInfo } from '../../global/selectors/threads';
import { IS_TAURI } from '../../util/browser/globalEnvironment'; import { IS_TAURI } from '../../util/browser/globalEnvironment';
import { import {
IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED, IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED,

View File

@ -31,9 +31,8 @@ import {
selectPinnedIds, selectPinnedIds,
selectScheduledIds, selectScheduledIds,
selectTabState, selectTabState,
selectThreadInfo,
selectThreadParam,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectThreadInfo, selectThreadLocalStateParam } from '../../global/selectors/threads';
import { IS_TAURI } from '../../util/browser/globalEnvironment'; import { IS_TAURI } from '../../util/browser/globalEnvironment';
import { IS_MAC_OS } from '../../util/browser/windowEnvironment'; import { IS_MAC_OS } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -400,7 +399,7 @@ export default memo(withGlobal<OwnProps>(
messagesCount = threadInfo?.messagesCount || 0; messagesCount = threadInfo?.messagesCount || 0;
} }
const typingStatus = selectThreadParam(global, chatId, threadId, 'typingStatus'); const typingStatus = selectThreadLocalStateParam(global, chatId, threadId, 'typingStatus');
const emojiStatus = peer?.emojiStatus; const emojiStatus = peer?.emojiStatus;
const emojiStatusSticker = emojiStatus && selectCustomEmoji(global, emojiStatus.documentId); const emojiStatusSticker = emojiStatus && selectCustomEmoji(global, emojiStatus.documentId);

View File

@ -13,10 +13,7 @@ import {
selectCanAnimateInterface, selectCanAnimateInterface,
selectChat, selectChat,
selectChatMessage, selectChatMessage,
selectDraft,
selectEditingId,
selectEditingMessage, selectEditingMessage,
selectEditingScheduledId,
selectForwardedSender, selectForwardedSender,
selectIsChatWithSelf, selectIsChatWithSelf,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
@ -25,6 +22,7 @@ import {
selectTheme, selectTheme,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { selectIsMediaNsfw } from '../../../global/selectors/media'; import { selectIsMediaNsfw } from '../../../global/selectors/media';
import { selectDraft, selectEditingId, selectEditingScheduledId } from '../../../global/selectors/threads';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { unique } from '../../../util/iteratees'; import { unique } from '../../../util/iteratees';

View File

@ -16,8 +16,9 @@ import type { Signal } from '../../../util/signals';
import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../config'; import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../config';
import { requestForcedReflow, requestMutation } from '../../../lib/fasterdom/fasterdom'; 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 { selectSharedSettings } from '../../../global/selectors/sharedState';
import { selectDraft } from '../../../global/selectors/threads';
import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_TAURI } from '../../../util/browser/globalEnvironment';
import { import {
IS_ANDROID, IS_EMOJI_SUPPORTED, IS_IOS, IS_TOUCH_ENV, IS_ANDROID, IS_EMOJI_SUPPORTED, IS_IOS, IS_TOUCH_ENV,

View File

@ -16,7 +16,8 @@ import {
getWebPagePhoto, getWebPagePhoto,
getWebPageVideo, getWebPageVideo,
} from '../../../global/helpers'; } 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 buildClassName from '../../../util/buildClassName';
import useThumbnail from '../../../hooks/media/useThumbnail'; import useThumbnail from '../../../hooks/media/useThumbnail';

View File

@ -34,6 +34,8 @@ export default function useMessageObservers(
} = useIntersectionObserver({ } = useIntersectionObserver({
rootRef: containerRef, rootRef: containerRef,
throttleMs: INTERSECTION_THROTTLE_FOR_READING, 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) => { }, (entries) => {
if (type !== 'thread' || isBackgroundModeActive()) { if (type !== 'thread' || isBackgroundModeActive()) {
return; return;
@ -97,7 +99,7 @@ export default function useMessageObservers(
} }
if (reactionIds.length) { if (reactionIds.length) {
animateUnreadReaction({ messageIds: reactionIds }); animateUnreadReaction({ chatId, messageIds: reactionIds });
} }
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) { if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) {

View File

@ -34,6 +34,7 @@ import {
selectSender, selectSender,
selectTabState, selectTabState,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { selectThreadReadState } from '../../../global/selectors/threads';
import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_TAURI } from '../../../util/browser/globalEnvironment';
import { IS_ANDROID, IS_FLUID_BACKGROUND_SUPPORTED } from '../../../util/browser/windowEnvironment'; import { IS_ANDROID, IS_FLUID_BACKGROUND_SUPPORTED } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
@ -295,7 +296,7 @@ const ActionMessage = ({
if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (!bottomMarker || !isElementInViewport(bottomMarker)) return;
if (hasUnreadReaction) { if (hasUnreadReaction) {
animateUnreadReaction({ messageIds: [id] }); animateUnreadReaction({ chatId, messageIds: [id] });
} }
if (message.hasUnreadMention) { if (message.hasUnreadMention) {
@ -672,7 +673,8 @@ export default memo(withGlobal<OwnProps>(
const isCurrentUserPremium = selectIsCurrentUserPremium(global); 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); const isAccountFrozen = selectIsCurrentUserFrozen(global);
return { return {

View File

@ -23,9 +23,9 @@ import {
selectMonoforumChannel, selectMonoforumChannel,
selectPeer, selectPeer,
selectSender, selectSender,
selectThreadIdFromMessage,
selectTopic, selectTopic,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { selectThreadIdFromMessage } from '../../../global/selectors/threads';
import { ensureProtocol } from '../../../util/browser/url'; import { ensureProtocol } from '../../../util/browser/url';
import { formatDateTimeToString, formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat'; import { formatDateTimeToString, formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat';
import { formatCurrency } from '../../../util/formatCurrency'; import { formatCurrency } from '../../../util/formatCurrency';

View File

@ -1,8 +1,8 @@
import type { FC } from '../../../lib/teact/teact';
import { memo, useMemo } from '../../../lib/teact/teact'; import { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global'; import { getActions, getGlobal } from '../../../global';
import type { ApiCommentsInfo } from '../../../api/types'; import type { ApiCommentsInfo } from '../../../api/types';
import type { ThreadReadState } from '../../../types';
import { selectIsCurrentUserFrozen, selectPeer } from '../../../global/selectors'; import { selectIsCurrentUserFrozen, selectPeer } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
@ -22,6 +22,7 @@ import './CommentButton.scss';
type OwnProps = { type OwnProps = {
threadInfo?: ApiCommentsInfo; threadInfo?: ApiCommentsInfo;
threadReadState?: ThreadReadState;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
isCustomShape?: boolean; isCustomShape?: boolean;
@ -29,12 +30,13 @@ type OwnProps = {
const SHOW_LOADER_DELAY = 450; const SHOW_LOADER_DELAY = 450;
const CommentButton: FC<OwnProps> = ({ const CommentButton = ({
isCustomShape, isCustomShape,
threadInfo, threadInfo,
threadReadState,
disabled, disabled,
isLoading, isLoading,
}) => { }: OwnProps) => {
const { openThread, openFrozenAccountModal } = getActions(); const { openThread, openFrozenAccountModal } = getActions();
const shouldRenderLoading = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); const shouldRenderLoading = useAsyncRendering([isLoading], SHOW_LOADER_DELAY);
@ -42,8 +44,9 @@ const CommentButton: FC<OwnProps> = ({
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
const { const {
originMessageId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId, originMessageId, chatId, messagesCount, lastMessageId, recentReplierIds, originChannelId,
} = threadInfo || {}; } = threadInfo || {};
const { lastReadInboxMessageId } = threadReadState || {};
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
const global = getGlobal(); const global = getGlobal();

View File

@ -65,15 +65,14 @@ import {
selectPollFromMessage, selectPollFromMessage,
selectRequestedChatTranslationLanguage, selectRequestedChatTranslationLanguage,
selectRequestedMessageTranslationLanguage, selectRequestedMessageTranslationLanguage,
selectSavedDialogIdFromMessage,
selectStickerSet, selectStickerSet,
selectThreadInfo,
selectTopic, selectTopic,
selectUser, selectUser,
selectUserStatus, selectUserStatus,
selectWebPageFromMessage, selectWebPageFromMessage,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { selectMessageDownloadableMedia } from '../../../global/selectors/media'; import { selectMessageDownloadableMedia } from '../../../global/selectors/media';
import { selectSavedDialogIdFromMessage, selectThreadInfo } from '../../../global/selectors/threads';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard'; import { copyTextToClipboard } from '../../../util/clipboard';
import { isUserId } from '../../../util/entities/ids'; import { isUserId } from '../../../util/entities/ids';
@ -837,7 +836,9 @@ export default memo(withGlobal<OwnProps>(
const isOwn = isOwnMessage(message); const isOwn = isOwnMessage(message);
const chatBot = chat && selectBot(global, chat.id); const chatBot = chat && selectBot(global, chat.id);
const isBot = Boolean(chatBot); 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( const canLoadReadDate = Boolean(
isPrivate isPrivate
&& isOwn && isOwn

View File

@ -39,6 +39,7 @@ import type {
TextSummary, TextSummary,
ThemeKey, ThemeKey,
ThreadId, ThreadId,
ThreadReadState,
} from '../../../types'; } from '../../../types';
import type { Signal } from '../../../util/signals'; import type { Signal } from '../../../util/signals';
import type { OnIntersectPinnedMessage } from '../hooks/usePinnedMessage'; import type { OnIntersectPinnedMessage } from '../hooks/usePinnedMessage';
@ -112,7 +113,6 @@ import {
selectShouldLoopStickers, selectShouldLoopStickers,
selectTabState, selectTabState,
selectTheme, selectTheme,
selectThreadInfo,
selectTopicFromMessage, selectTopicFromMessage,
selectUploadProgress, selectUploadProgress,
selectUser, selectUser,
@ -124,6 +124,7 @@ import {
selectMessageTimestampableDuration, selectMessageTimestampableDuration,
} from '../../../global/selectors/media'; } from '../../../global/selectors/media';
import { selectSharedSettings } from '../../../global/selectors/sharedState'; import { selectSharedSettings } from '../../../global/selectors/sharedState';
import { selectThread, selectThreadReadState } from '../../../global/selectors/threads';
import { IS_TAURI } from '../../../util/browser/globalEnvironment'; import { IS_TAURI } from '../../../util/browser/globalEnvironment';
import { IS_ANDROID, IS_TRANSLATION_SUPPORTED } from '../../../util/browser/windowEnvironment'; import { IS_ANDROID, IS_TRANSLATION_SUPPORTED } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
@ -293,6 +294,7 @@ type StateProps = {
shouldLoopStickers?: boolean; shouldLoopStickers?: boolean;
autoLoadFileMaxSizeMb: number; autoLoadFileMaxSizeMb: number;
repliesThreadInfo?: ApiThreadInfo; repliesThreadInfo?: ApiThreadInfo;
repliesReadState?: ThreadReadState;
reactionMessage?: ApiMessage; reactionMessage?: ApiMessage;
availableReactions?: ApiAvailableReaction[]; availableReactions?: ApiAvailableReaction[];
defaultReaction?: ApiReaction; defaultReaction?: ApiReaction;
@ -428,6 +430,7 @@ const Message = ({
shouldLoopStickers, shouldLoopStickers,
autoLoadFileMaxSizeMb, autoLoadFileMaxSizeMb,
repliesThreadInfo, repliesThreadInfo,
repliesReadState,
hasUnreadReaction, hasUnreadReaction,
memoFirstUnreadIdRef, memoFirstUnreadIdRef,
senderAdminMember, senderAdminMember,
@ -839,6 +842,7 @@ const Message = ({
const phoneCall = action?.type === 'phoneCall' ? action : undefined; const phoneCall = action?.type === 'phoneCall' ? action : undefined;
const commentsThreadInfo = repliesThreadInfo?.isCommentsInfo ? repliesThreadInfo : undefined; const commentsThreadInfo = repliesThreadInfo?.isCommentsInfo ? repliesThreadInfo : undefined;
const commentsReadState = repliesThreadInfo?.isCommentsInfo ? repliesReadState : undefined;
const isLocalWithCommentButton = hasLinkedChat && isChannel && isLocal; const isLocalWithCommentButton = hasLinkedChat && isChannel && isLocal;
const isMediaWithCommentButton = (commentsThreadInfo || isLocalWithCommentButton) const isMediaWithCommentButton = (commentsThreadInfo || isLocalWithCommentButton)
@ -872,7 +876,8 @@ const Message = ({
asForwarded, asForwarded,
hasThread: hasThread && !noComments, hasThread: hasThread && !noComments,
forceSenderName, forceSenderName,
hasCommentCounter: hasThread && repliesThreadInfo.messagesCount > 0, hasCommentCounter: hasThread && repliesThreadInfo.messagesCount !== undefined
&& repliesThreadInfo.messagesCount > 0,
hasBottomCommentButton: withCommentButton && !isCustomShape, hasBottomCommentButton: withCommentButton && !isCustomShape,
hasActionButton: canForward || canFocus || (withCommentButton && isCustomShape), hasActionButton: canForward || canFocus || (withCommentButton && isCustomShape),
hasReactions, hasReactions,
@ -955,7 +960,7 @@ const Message = ({
if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (!bottomMarker || !isElementInViewport(bottomMarker)) return;
if (hasUnreadReaction) { if (hasUnreadReaction) {
animateUnreadReaction({ messageIds: [messageId] }); animateUnreadReaction({ chatId, messageIds: [messageId] });
} }
let unreadMentionIds: number[] = []; let unreadMentionIds: number[] = [];
@ -1889,6 +1894,7 @@ const Message = ({
{withCommentButton && isCustomShape && ( {withCommentButton && isCustomShape && (
<CommentButton <CommentButton
threadInfo={commentsThreadInfo} threadInfo={commentsThreadInfo}
threadReadState={commentsReadState}
disabled={noComments || !commentsThreadInfo} disabled={noComments || !commentsThreadInfo}
isLoading={isLoadingComments} isLoading={isLoadingComments}
isCustomShape isCustomShape
@ -1920,6 +1926,7 @@ const Message = ({
{withCommentButton && !isCustomShape && ( {withCommentButton && !isCustomShape && (
<CommentButton <CommentButton
threadInfo={commentsThreadInfo} threadInfo={commentsThreadInfo}
threadReadState={commentsReadState}
disabled={noComments || !commentsThreadInfo} disabled={noComments || !commentsThreadInfo}
isLoading={isLoadingComments} isLoading={isLoadingComments}
/> />
@ -2086,7 +2093,8 @@ export default memo(withGlobal<OwnProps>(
const downloadableMedia = selectMessageDownloadableMedia(global, message); const downloadableMedia = selectMessageDownloadableMedia(global, message);
const isDownloading = downloadableMedia && getIsDownloading(activeDownloads, downloadableMedia); 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 isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum;
const documentGroupFirstMessageId = isInDocumentGroup const documentGroupFirstMessageId = isInDocumentGroup
@ -2096,7 +2104,8 @@ export default memo(withGlobal<OwnProps>(
isLastInDocumentGroup ? selectChatMessage(global, chatId, documentGroupFirstMessageId!) : undefined isLastInDocumentGroup ? selectChatMessage(global, chatId, documentGroupFirstMessageId!) : undefined
) : message; ) : 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 hasTopicChip = threadId === MAIN_THREAD_ID && chat?.isForum && !chat.isBotForum && isFirstInGroup;
const messageTopic = selectTopicFromMessage(global, message); const messageTopic = selectTopicFromMessage(global, message);
@ -2187,6 +2196,7 @@ export default memo(withGlobal<OwnProps>(
autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb, autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb,
shouldLoopStickers: selectShouldLoopStickers(global), shouldLoopStickers: selectShouldLoopStickers(global),
repliesThreadInfo, repliesThreadInfo,
repliesReadState,
availableReactions: global.reactions.availableReactions, availableReactions: global.reactions.availableReactions,
defaultReaction: isMessageLocal(message) || messageListType === 'scheduled' defaultReaction: isMessageLocal(message) || messageListType === 'scheduled'
? undefined : selectDefaultReaction(global, chatId), ? undefined : selectDefaultReaction(global, chatId),
@ -2212,7 +2222,9 @@ export default memo(withGlobal<OwnProps>(
&& loadingThread?.loadingChatId === repliesThreadInfo?.originChannelId && loadingThread?.loadingChatId === repliesThreadInfo?.originChannelId
&& loadingThread?.loadingMessageId === repliesThreadInfo?.originMessageId, && loadingThread?.loadingMessageId === repliesThreadInfo?.originMessageId,
shouldWarnAboutFiles, 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, uploadProgress: typeof uploadProgress === 'number' ? uploadProgress : undefined,
focusDirection: isFocused ? focusDirection : undefined, focusDirection: isFocused ? focusDirection : undefined,
noFocusHighlight: isFocused ? noFocusHighlight : undefined, noFocusHighlight: isFocused ? noFocusHighlight : undefined,

View File

@ -191,7 +191,7 @@ export default function useInnerHandlers({
}); });
const handleReadMedia = useLastCallback((): void => { const handleReadMedia = useLastCallback((): void => {
markMessagesRead({ messageIds: [messageId] }); markMessagesRead({ chatId, messageIds: [messageId] });
}); });
const handleCancelUpload = useLastCallback(() => { const handleCancelUpload = useLastCallback(() => {

View File

@ -7,7 +7,8 @@ import type { ThreadId } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { getIsSavedDialog } from '../../../global/helpers'; 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 { isUserId } from '../../../util/entities/ids';
import useConnectionStatus from '../../../hooks/useConnectionStatus'; import useConnectionStatus from '../../../hooks/useConnectionStatus';
@ -75,7 +76,7 @@ const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
round round
color="translucent" color="translucent"
size="smaller" size="smaller"
ariaLabel={lang('ChatListContextMaskAsRead')} ariaLabel={lang('ChatListContextMarkAsRead')}
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
className={styles.markAsReadButton} className={styles.markAsReadButton}
iconName="readchats" iconName="readchats"
@ -134,11 +135,10 @@ const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId }): Complete<StateProps> => { (global, { chatId, threadId }): Complete<StateProps> => {
const chat = selectChat(global, chatId); 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 isSavedDialog = getIsSavedDialog(chatId, threadId || MAIN_THREAD_ID, global.currentUserId);
const unreadCount = chat?.isForum && threadId const readState = selectThreadReadState(global, chatId, threadId || MAIN_THREAD_ID);
? selectTopic(global, chatId, threadId)?.unreadCount const unreadCount = readState?.unreadCount;
: chat?.unreadCount;
return { return {
chat, chat,
@ -148,7 +148,7 @@ export default memo(withGlobal<OwnProps>(
typingStatus, typingStatus,
isSavedDialog, isSavedDialog,
unreadCount, unreadCount,
hasUnreadMark: chat?.hasUnreadMark, hasUnreadMark: readState?.hasUnreadMark,
}; };
}, },
)(QuickPreviewModalHeader)); )(QuickPreviewModalHeader));

View File

@ -1,4 +1,4 @@
import { memo, useMemo } from '../../../../lib/teact/teact'; import { memo, useCallback, useMemo } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import type { import type {
@ -42,12 +42,6 @@ type OwnProps = {
const GIFT_STICKER_SIZE = 36; const GIFT_STICKER_SIZE = 36;
function selectOptionalPeer(peerId?: string) {
return (global: GlobalState) => (
peerId ? selectPeer(global, peerId) : undefined
);
}
const StarsTransactionItem = ({ transaction, className }: OwnProps) => { const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
const { openStarsTransactionModal } = getActions(); const { openStarsTransactionModal } = getActions();
const { const {
@ -62,7 +56,11 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
const oldLang = useOldLang(); const oldLang = useOldLang();
const peerId = transactionPeer.type === 'peer' ? transactionPeer.id : undefined; 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 starGift = transaction.starGift;
const isUniqueGift = starGift?.type === 'starGiftUnique'; const isUniqueGift = starGift?.type === 'starGiftUnique';
const giftSticker = starGift && getStickerFromGift(starGift); const giftSticker = starGift && getStickerFromGift(starGift);

View File

@ -14,7 +14,7 @@ import {
TON_CURRENCY_CODE, TON_CURRENCY_CODE,
} from '../../../config'; } from '../../../config';
import { selectIsMonoforumAdmin, selectPeer } from '../../../global/selectors'; import { selectIsMonoforumAdmin, selectPeer } from '../../../global/selectors';
import { selectDraft } from '../../../global/selectors/messages'; import { selectDraft } from '../../../global/selectors/threads';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat'; import { formatScheduledDateTime, formatShortDuration } from '../../../util/dates/dateFormat';
import { convertTonFromNanos, convertTonToNanos } from '../../../util/formatCurrency'; import { convertTonFromNanos, convertTonToNanos } from '../../../util/formatCurrency';

View File

@ -13,9 +13,10 @@ import {
selectCurrentGifSearch, selectCurrentGifSearch,
selectCurrentMessageList, selectCurrentMessageList,
selectIsChatWithBot, selectIsChatWithBot,
selectIsChatWithSelf, selectThreadInfo, selectIsChatWithSelf,
selectTopic, selectTopic,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectThreadInfo } from '../../global/selectors/threads';
import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment'; import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';

View File

@ -56,7 +56,6 @@ import {
selectChatMessage, selectChatMessage,
selectCurrentChat, selectCurrentChat,
selectCurrentMessageList, selectCurrentMessageList,
selectDraft,
selectIsCurrentUserFrozen, selectIsCurrentUserFrozen,
selectIsTrustedBot, selectIsTrustedBot,
selectMessageReplyInfo, selectMessageReplyInfo,
@ -68,6 +67,7 @@ import {
selectUserFullInfo, selectUserFullInfo,
} from '../../selectors'; } from '../../selectors';
import { selectSharedSettings } from '../../selectors/sharedState'; import { selectSharedSettings } from '../../selectors/sharedState';
import { selectDraft } from '../../selectors/threads.ts';
import { fetchChatByUsername } from './chats'; import { fetchChatByUsername } from './chats';
import { getPeerStarsForMessage } from './messages'; import { getPeerStarsForMessage } from './messages';

View File

@ -40,7 +40,7 @@ import { isUserId } from '../../../util/entities/ids';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { getOrderedIds } from '../../../util/folderManager'; import { getOrderedIds } from '../../../util/folderManager';
import { import {
buildCollectionByKey, omit, pick, unique, buildCollectionByKey, omit, unique,
} from '../../../util/iteratees'; } from '../../../util/iteratees';
import { isLocalMessageId } from '../../../util/keys/messageKey'; import { isLocalMessageId } from '../../../util/keys/messageKey';
import * as langProvider from '../../../util/oldLangProvider'; import * as langProvider from '../../../util/oldLangProvider';
@ -79,7 +79,6 @@ import {
replaceMessages, replaceMessages,
replaceNotifyExceptions, replaceNotifyExceptions,
replaceSimilarChannels, replaceSimilarChannels,
replaceThreadParam,
replaceUserStatuses, replaceUserStatuses,
toggleSimilarChannels, toggleSimilarChannels,
updateChat, updateChat,
@ -92,15 +91,22 @@ import {
updateManagementProgress, updateManagementProgress,
updateMissingInvitedUsers, updateMissingInvitedUsers,
updatePeerFullInfo, updatePeerFullInfo,
updateThread,
updateThreadInfo,
updateTopic, updateTopic,
updateTopics, updateTopicsInfo,
updateTopicWithState,
updateUser, updateUser,
updateUsers, updateUsers,
} from '../../reducers'; } from '../../reducers';
import { updateGroupCall } from '../../reducers/calls'; import { updateGroupCall } from '../../reducers/calls';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import {
replaceThreadLocalStateParam,
replaceThreadReadStateParam,
updateMainThreadReadStates,
updateThreadInfo,
updateThreadInfoLastMessageId,
updateThreadReadState,
} from '../../reducers/threads';
import { import {
selectChat, selectChat,
selectChatByUsername, selectChatByUsername,
@ -112,7 +118,6 @@ import {
selectChatMessages, selectChatMessages,
selectCurrentChat, selectCurrentChat,
selectCurrentMessageList, selectCurrentMessageList,
selectDraft,
selectIsChatPinned, selectIsChatPinned,
selectIsChatWithSelf, selectIsChatWithSelf,
selectIsCurrentUserFrozen, selectIsCurrentUserFrozen,
@ -123,8 +128,6 @@ import {
selectStickerSet, selectStickerSet,
selectSupportChat, selectSupportChat,
selectTabState, selectTabState,
selectThread,
selectThreadInfo,
selectTopic, selectTopic,
selectTopics, selectTopics,
selectTopicsInfo, selectTopicsInfo,
@ -133,6 +136,13 @@ import {
} from '../../selectors'; } from '../../selectors';
import { selectGroupCall } from '../../selectors/calls'; import { selectGroupCall } from '../../selectors/calls';
import { selectCurrentLimit } from '../../selectors/limits'; import { selectCurrentLimit } from '../../selectors/limits';
import {
selectDraft,
selectThread,
selectThreadInfo,
selectThreadLocalState,
selectThreadReadState,
} from '../../selectors/threads';
const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100; const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100;
const INFINITE_LOOP_MARKER = 100; const INFINITE_LOOP_MARKER = 100;
@ -227,8 +237,9 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
} }
const chat = selectChat(global, id); const chat = selectChat(global, id);
const chatReadState = selectThreadReadState(global, id, MAIN_THREAD_ID);
if (chat?.hasUnreadMark) { if (chatReadState?.hasUnreadMark) {
actions.markChatRead({ id }); actions.markChatRead({ id });
} }
@ -308,14 +319,17 @@ addActionHandler('openThread', async (global, actions, payload): Promise<void> =
const chat = selectChat(global, loadingChatId); const chat = selectChat(global, loadingChatId);
const threadInfo = selectThreadInfo(global, loadingChatId, loadingThreadId); const threadInfo = selectThreadInfo(global, loadingChatId, loadingThreadId);
const thread = selectThread(global, loadingChatId, loadingThreadId); const threadLocalState = selectThreadLocalState(global, loadingChatId, loadingThreadId);
if (!chat) return; if (!chat) return;
abortChatRequestsForCurrentChat(global, loadingChatId, loadingThreadId, tabId); abortChatRequestsForCurrentChat(global, loadingChatId, loadingThreadId, tabId);
if (chatId if (chatId
&& threadInfo?.threadId && 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, { global = updateTabState(global, {
loadingThread: undefined, loadingThread: undefined,
}, tabId); }, tabId);
@ -407,29 +421,23 @@ addActionHandler('openThread', async (global, actions, payload): Promise<void> =
global = getGlobal(); global = getGlobal();
global = addMessages(global, result.messages); global = addMessages(global, result.messages);
if (isComments) { global = updateThreadReadState(global, chatId, result.threadId, result.threadReadState);
global = updateThreadInfo(global, loadingChatId, loadingThreadId, { global = updateThreadInfoLastMessageId(global, chatId, result.threadId, result.lastMessageId);
threadId,
});
if (isComments) {
const lastMessageId = threadInfo?.lastMessageId !== undefined ? threadInfo.lastMessageId const lastMessageId = threadInfo?.lastMessageId !== undefined ? threadInfo.lastMessageId
: threadInfo?.messagesCount === 0 ? result.threadId : undefined; : threadInfo?.messagesCount === 0 ? result.threadId : undefined;
global = updateThreadInfo(global, chatId, threadId, { global = updateThreadInfo(global, {
isCommentsInfo: false, isCommentsInfo: false,
threadId, threadId,
chatId, chatId,
fromChannelId: loadingChatId, fromChannelId: loadingChatId,
fromMessageId: loadingThreadId, fromMessageId: loadingThreadId,
lastMessageId, lastMessageId,
...(threadInfo
&& pick(threadInfo, ['messagesCount', 'lastReadInboxMessageId', 'recentReplierIds'])
),
}); });
} }
global = updateThread(global, chatId, threadId, { global = replaceThreadLocalStateParam(global, chatId, threadId, 'firstMessageId', result.firstMessageId);
firstMessageId: result.firstMessageId,
});
setGlobal(global); setGlobal(global);
if (focusMessageId) { if (focusMessageId) {
@ -535,16 +543,36 @@ addActionHandler('loadAllChats', async (global, actions, payload): Promise<void>
return; return;
} }
await loadChats( const result = await loadChats(
listType, listType,
true, true,
); );
const isFirstBatch = !isCallbackFired;
if (!isCallbackFired) { if (!isCallbackFired) {
await whenFirstBatchDone?.(); await whenFirstBatchDone?.();
isCallbackFired = true; 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(); global = getGlobal();
} }
}); });
@ -1288,11 +1316,12 @@ addActionHandler('markChatUnread', (global, actions, payload): ActionReturnType
actions.openFrozenAccountModal({ tabId: getCurrentTabId() }); actions.openFrozenAccountModal({ tabId: getCurrentTabId() });
return; return;
} }
const chat = selectChat(global, id); const chat = selectChat(global, id);
if (!chat) return; if (!chat) return;
void callApi('toggleDialogUnread', { void callApi('toggleDialogUnread', {
chat, chat,
hasUnreadMark: !chat.hasUnreadMark, hasUnreadMark: true,
}); });
}); });
@ -1306,12 +1335,14 @@ addActionHandler('markChatMessagesRead', async (global, actions, payload): Promi
} }
const chat = selectChat(global, id); const chat = selectChat(global, id);
const chatReadState = selectThreadReadState(global, id, MAIN_THREAD_ID);
if (!chat) return; if (!chat) return;
if (!chat.isForum) { if (!chat.isForum) {
await callApi('markMessageListRead', { chat, threadId: MAIN_THREAD_ID }); await callApi('markMessageListRead', { chat, threadId: MAIN_THREAD_ID });
actions.readAllMentions({ chatId: id }); actions.readAllMentions({ chatId: id });
actions.readAllReactions({ chatId: id }); actions.readAllReactions({ chatId: id });
if (chat.hasUnreadMark) { if (chatReadState?.hasUnreadMark) {
actions.markChatRead({ id }); actions.markChatRead({ id });
} }
return; return;
@ -1322,18 +1353,29 @@ addActionHandler('markChatMessagesRead', async (global, actions, payload): Promi
let processedCount = 0; let processedCount = 0;
while (hasMoreTopics) { while (hasMoreTopics) {
const lastTopicThreadInfo = lastTopic && selectThreadInfo(global, id, lastTopic.id);
const result = await callApi('fetchTopics', { 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; if (!result?.topics?.length) return;
result.topics.forEach((topic) => { result.topics.forEach((topicWithState) => {
if (!topic.unreadCount && !topic.unreadMentionsCount && !topic.unreadReactionsCount) return; global = updateTopicWithState(global, id, topicWithState);
actions.markTopicRead({ chatId: id, topicId: topic.id });
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; processedCount += result.topics.length;
if (result.count <= processedCount) { if (result.count <= processedCount) {
hasMoreTopics = false; hasMoreTopics = false;
@ -1348,7 +1390,7 @@ addActionHandler('markChatRead', (global, actions, payload): ActionReturnType =>
callApi('toggleDialogUnread', { callApi('toggleDialogUnread', {
chat, chat,
hasUnreadMark: !chat.hasUnreadMark, hasUnreadMark: undefined,
}); });
}); });
@ -1358,9 +1400,8 @@ addActionHandler('markTopicRead', (global, actions, payload): ActionReturnType =
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat) return; if (!chat) return;
const topic = selectTopic(global, chatId, topicId); const lastTopicThreadInfo = selectThreadInfo(global, chatId, topicId);
const lastTopicMessageId = lastTopicThreadInfo?.lastMessageId;
const lastTopicMessageId = topic?.lastMessageId;
if (!lastTopicMessageId) return; if (!lastTopicMessageId) return;
void callApi('markMessageListRead', { void callApi('markMessageListRead', {
@ -1372,12 +1413,8 @@ addActionHandler('markTopicRead', (global, actions, payload): ActionReturnType =
actions.readAllReactions({ chatId, threadId: topicId }); actions.readAllReactions({ chatId, threadId: topicId });
global = getGlobal(); global = getGlobal();
global = updateTopic(global, chatId, topicId, { global = replaceThreadReadStateParam(global, chatId, topicId, 'lastReadInboxMessageId', lastTopicMessageId);
unreadCount: 0, global = replaceThreadReadStateParam(global, chatId, topicId, 'unreadCount', 0);
});
global = updateThreadInfo(global, chatId, topicId, {
lastReadInboxMessageId: lastTopicMessageId,
});
setGlobal(global); setGlobal(global);
}); });
@ -2396,16 +2433,19 @@ addActionHandler('loadTopics', async (global, actions, payload): Promise<void> =
} }
const offsetTopic = !force ? topicsInfo?.listedTopicIds?.reduce((acc, el) => { const offsetTopic = !force ? topicsInfo?.listedTopicIds?.reduce((acc, el) => {
const topic = selectTopic(global, chatId, el); const topicThreadInfo = selectThreadInfo(global, chatId, el);
const accTopic = selectTopic(global, chatId, acc); const accTopicThreadInfo = selectThreadInfo(global, chatId, acc);
if (!topic) return acc; if (!topicThreadInfo?.lastMessageId) return acc;
if (!accTopic || topic.lastMessageId < accTopic.lastMessageId) { if (!accTopicThreadInfo?.lastMessageId || topicThreadInfo.lastMessageId < accTopicThreadInfo.lastMessageId) {
return el; return el;
} }
return acc; return acc;
}) : undefined; }) : 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)) || {}; && selectTopic(global, chatId, offsetTopic)) || {};
const result = await callApi('fetchTopics', { const result = await callApi('fetchTopics', {
chat, offsetTopicId, offsetId, offsetDate, limit: offsetTopicId ? TOPICS_SLICE : TOPICS_SLICE_SECOND_LOAD, chat, offsetTopicId, offsetId, offsetDate, limit: offsetTopicId ? TOPICS_SLICE : TOPICS_SLICE_SECOND_LOAD,
@ -2415,13 +2455,15 @@ addActionHandler('loadTopics', async (global, actions, payload): Promise<void> =
global = getGlobal(); global = getGlobal();
global = addMessages(global, result.messages); global = addMessages(global, result.messages);
global = updateTopics(global, chatId, result.count, result.topics); result.topics.forEach((topic) => {
global = updateListedTopicIds(global, chatId, result.topics.map((topic) => topic.id)); global = updateTopicWithState(global, chatId, topic);
Object.entries(result.draftsById || {}).forEach(([threadId, draft]) => {
global = replaceThreadParam(global, chatId, Number(threadId), 'draft', draft);
}); });
Object.entries(result.readInboxMessageIdByTopicId || {}).forEach(([topicId, messageId]) => { global = updateTopicsInfo(global, chatId, {
global = updateThreadInfo(global, chatId, Number(topicId), { lastReadInboxMessageId: messageId }); 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); setGlobal(global);
@ -2445,8 +2487,7 @@ addActionHandler('loadTopicById', async (global, actions, payload): Promise<void
global = getGlobal(); global = getGlobal();
global = addMessages(global, result.messages); global = addMessages(global, result.messages);
global = updateTopic(global, chatId, topicId, result.topic); global = updateTopicWithState(global, chatId, result.topic);
setGlobal(global); setGlobal(global);
}); });
@ -2918,7 +2959,7 @@ addActionHandler('updateChatDetectedLanguage', (global, actions, payload): Actio
global = getGlobal(); global = getGlobal();
global = updateChat(global, chatId, { global = updateChat(global, chatId, {
detectedLanguage, detectedLanguage,
}, undefined, true); });
return global; return global;
}); });
@ -3153,8 +3194,10 @@ async function loadChats(
const isFirstBatch = !shouldIgnorePagination && !offsetPeer && !offsetDate && !offsetId; const isFirstBatch = !shouldIgnorePagination && !offsetPeer && !offsetDate && !offsetId;
const shouldReplaceStaleState = listType === 'active' && isFirstBatch; const shouldReplaceStaleState = listType === 'active' && isFirstBatch;
const isAccountFreeze = selectIsCurrentUserFrozen(global); const isAccountFreeze = selectIsCurrentUserFrozen(global);
const currentUser = selectUser(global, global.currentUserId!)!;
const result = listType === 'saved' ? await callApi('fetchSavedChats', { const result = listType === 'saved' ? await callApi('fetchSavedChats', {
parentPeer: currentUser,
limit: CHAT_LIST_LOAD_SLICE, limit: CHAT_LIST_LOAD_SLICE,
offsetDate, offsetDate,
offsetId, offsetId,
@ -3198,7 +3241,6 @@ async function loadChats(
} }
global = updateChatListSecondaryInfo(global, listType, result); global = updateChatListSecondaryInfo(global, listType, result);
global = replaceMessages(global, result.messages);
global = updateChatsLastMessageId(global, result.lastMessageByChatId, listType); global = updateChatsLastMessageId(global, result.lastMessageByChatId, listType);
if (!shouldIgnorePagination) { if (!shouldIgnorePagination) {
@ -3216,7 +3258,7 @@ async function loadChats(
if (!draft && !thread) return; if (!draft && !thread) return;
if (!selectDraft(global, chatId, MAIN_THREAD_ID)?.isLocal) { if (!selectDraft(global, chatId, MAIN_THREAD_ID)?.isLocal) {
global = replaceThreadParam( global = replaceThreadLocalStateParam(
global, chatId, MAIN_THREAD_ID, 'draft', draft, global, chatId, MAIN_THREAD_ID, 'draft', draft,
); );
} }
@ -3237,6 +3279,12 @@ async function loadChats(
} }
setGlobal(global); setGlobal(global);
return {
threadInfos: result.threadInfos,
threadReadStatesById: result.threadReadStatesById,
messages: result.messages,
};
} }
export async function loadFullChat<T extends GlobalState>( export async function loadFullChat<T extends GlobalState>(
@ -3540,7 +3588,7 @@ async function openChatWithParams<T extends GlobalState>(
let topic = selectTopics(global, chat.id)?.[messageId]; let topic = selectTopics(global, chat.id)?.[messageId];
if (!topic) { if (!topic) {
const topicResult = await callApi('fetchTopicById', { chat, topicId: messageId }); const topicResult = await callApi('fetchTopicById', { chat, topicId: messageId });
topic = topicResult?.topic; topic = topicResult?.topic.topic;
} }
if (topic) { if (topic) {

View File

@ -1,7 +1,8 @@
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { import type {
ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiMessageSearchContext, ApiPeer, ApiSearchPostsFlood, ApiTopic, ApiChat, ApiGlobalMessageSearchType, ApiMessage, ApiMessageSearchContext, ApiPeer, ApiSearchPostsFlood,
ApiTopicWithState,
ApiUserStatus, ApiUserStatus,
} from '../../../api/types'; } from '../../../api/types';
import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types';
@ -24,7 +25,7 @@ import {
updateGlobalSearch, updateGlobalSearch,
updateGlobalSearchFetchingStatus, updateGlobalSearchFetchingStatus,
updateGlobalSearchResults, updateGlobalSearchResults,
updateTopics, updateTopicWithState,
} from '../../reducers'; } from '../../reducers';
import { import {
selectChat, selectChatByUsername, selectChatMessage, selectCurrentGlobalSearchQuery, selectPeer, selectTabState, selectChat, selectChatByUsername, selectChatMessage, selectCurrentGlobalSearchQuery, selectPeer, selectTabState,
@ -205,7 +206,7 @@ async function searchMessagesGlobal<T extends GlobalState>(global: T, params: {
let result: { let result: {
messages: ApiMessage[]; messages: ApiMessage[];
userStatusesById?: Record<number, ApiUserStatus>; userStatusesById?: Record<number, ApiUserStatus>;
topics?: ApiTopic[]; topics?: ApiTopicWithState[];
totalTopicsCount?: number; totalTopicsCount?: number;
totalCount: number; totalCount: number;
nextOffsetRate?: number; nextOffsetRate?: number;
@ -239,13 +240,13 @@ async function searchMessagesGlobal<T extends GlobalState>(global: T, params: {
if (inChatResult) { if (inChatResult) {
const { const {
messages, totalCount, nextOffsetId, messages, totalCount, nextOffsetId, topics: messagesTopics,
} = inChatResult; } = inChatResult;
const { topics: localTopics, count } = topics || {}; const { topics: localTopics, count } = topics || {};
result = { result = {
topics: localTopics, topics: messagesTopics.concat(localTopics || []),
totalTopicsCount: count, totalTopicsCount: count,
messages, messages,
totalCount, totalCount,
@ -264,11 +265,16 @@ async function searchMessagesGlobal<T extends GlobalState>(global: T, params: {
maxDate, maxDate,
minDate, minDate,
}); });
if (isDeepLink(query)) { if (isDeepLink(query)) {
const link = tryParseDeepLink(query); const link = tryParseDeepLink(query);
if (link?.type === 'publicMessageLink') { if (link?.type === 'publicMessageLink') {
global = getGlobal();
messageLink = await getMessageByPublicLink(global, link); messageLink = await getMessageByPublicLink(global, link);
} else if (link?.type === 'privateMessageLink') { }
if (link?.type === 'privateMessageLink') {
global = getGlobal();
messageLink = await getMessageByPrivateLink(global, link); messageLink = await getMessageByPrivateLink(global, link);
} }
} }
@ -321,13 +327,17 @@ async function searchMessagesGlobal<T extends GlobalState>(global: T, params: {
tabId, tabId,
); );
if (result.topics) { if (peer && result.topics?.length) {
global = updateTopics(global, peer!.id, result.totalTopicsCount!, result.topics); 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, { global = updateGlobalSearch(global, {
foundTopicIds: sortedTopics, foundTopicIds: sortedTopicIds,
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);

View File

@ -63,6 +63,7 @@ import { callApi, cancelApiProgress } from '../../../api/gramjs';
import { import {
getIsSavedDialog, getIsSavedDialog,
getUserFullName, getUserFullName,
groupMessageIdsByThreadId,
isChatChannel, isChatChannel,
isChatSuperGroup, isChatSuperGroup,
isDeletedUser, isDeletedUser,
@ -73,7 +74,7 @@ import {
} from '../../helpers'; } from '../../helpers';
import { isApiPeerChat, isApiPeerUser } from '../../helpers/peers'; import { isApiPeerChat, isApiPeerUser } from '../../helpers/peers';
import { import {
addActionHandler, getActions, getGlobal, setGlobal, addActionHandler, getActions, getGlobal, getPromiseActions, setGlobal,
} from '../../index'; } from '../../index';
import { import {
addChatMessagesById, addChatMessagesById,
@ -84,7 +85,6 @@ import {
removeRequestedMessageTranslation, removeRequestedMessageTranslation,
removeUnreadMentions, removeUnreadMentions,
replaceSettings, replaceSettings,
replaceThreadParam,
replaceUserStatuses, replaceUserStatuses,
safeReplacePinnedIds, safeReplacePinnedIds,
safeReplaceViewportIds, safeReplaceViewportIds,
@ -102,13 +102,18 @@ import {
updateRequestedMessageTranslation, updateRequestedMessageTranslation,
updateScheduledMessages, updateScheduledMessages,
updateSponsoredMessage, updateSponsoredMessage,
updateThreadInfo, updateTopicWithState,
updateThreadUnreadFromForwardedMessage,
updateTopic,
updateUploadByMessageKey, updateUploadByMessageKey,
updateUserFullInfo, updateUserFullInfo,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import {
replaceThreadLocalStateParam,
replaceThreadReadStateParam,
updateThreadInfo,
updateThreadInfoMessagesCount,
updateThreadReadState,
} from '../../reducers/threads';
import { import {
selectCanForwardMessage, selectCanForwardMessage,
selectChat, selectChat,
@ -119,10 +124,7 @@ import {
selectCurrentMessageList, selectCurrentMessageList,
selectCurrentViewedStory, selectCurrentViewedStory,
selectCustomEmoji, selectCustomEmoji,
selectDraft,
selectEditingId,
selectEditingMessage, selectEditingMessage,
selectEditingScheduledId,
selectFirstMessageId, selectFirstMessageId,
selectFirstUnreadId, selectFirstUnreadId,
selectFocusedMessageId, selectFocusedMessageId,
@ -137,7 +139,6 @@ import {
selectLanguageCode, selectLanguageCode,
selectListedIds, selectListedIds,
selectMessageReplyInfo, selectMessageReplyInfo,
selectNoWebPage,
selectOutlyingListByMessageId, selectOutlyingListByMessageId,
selectPeer, selectPeer,
selectPeerStory, selectPeerStory,
@ -145,20 +146,26 @@ import {
selectPollFromMessage, selectPollFromMessage,
selectRealLastReadId, selectRealLastReadId,
selectReplyCanBeSentToChat, selectReplyCanBeSentToChat,
selectSavedDialogIdFromMessage,
selectScheduledMessage, selectScheduledMessage,
selectSendAs, selectSendAs,
selectTabState, selectTabState,
selectThreadIdFromMessage,
selectThreadInfo,
selectThreadParam,
selectTopic,
selectTranslationLanguage, selectTranslationLanguage,
selectUser, selectUser,
selectUserFullInfo, selectUserFullInfo,
selectUserStatus, selectUserStatus,
selectViewportIds, selectViewportIds,
} from '../../selectors'; } from '../../selectors';
import {
selectDraft,
selectEditingId,
selectEditingScheduledId,
selectNoWebPage,
selectSavedDialogIdFromMessage,
selectThreadIdFromMessage,
selectThreadInfo,
selectThreadLocalStateParam,
selectThreadReadState,
} from '../../selectors/threads';
import { updateWithLocalMedia } from '../apiUpdaters/messages'; import { updateWithLocalMedia } from '../apiUpdaters/messages';
import { deleteMessages } from '../apiUpdaters/messages'; import { deleteMessages } from '../apiUpdaters/messages';
@ -299,7 +306,7 @@ async function loadWithBudget<T extends GlobalState>(
addActionHandler('loadMessage', async (global, actions, payload): Promise<void> => { addActionHandler('loadMessage', async (global, actions, payload): Promise<void> => {
const { const {
chatId, messageId, replyOriginForId, threadUpdate, chatId, messageId, replyOriginForId,
} = payload; } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
@ -307,20 +314,28 @@ addActionHandler('loadMessage', async (global, actions, payload): Promise<void>
return; return;
} }
const message = await loadMessage(global, chat, messageId, replyOriginForId); const result = await callApi('fetchMessage', { chat, messageId });
if (message && threadUpdate) { if (!result) {
const { lastMessageId, isDeleting } = threadUpdate; return undefined;
global = getGlobal();
global = updateThreadUnreadFromForwardedMessage(
global,
message,
chatId,
lastMessageId,
isDeleting,
);
setGlobal(global);
} }
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<void> => { addActionHandler('loadMessagesById', async (global, actions, payload): Promise<void> => {
@ -953,14 +968,11 @@ async function saveDraft<T extends GlobalState>({
const newDraft: ApiDraft | undefined = draft ? { const newDraft: ApiDraft | undefined = draft ? {
...draft, ...draft,
replyInfo, replyInfo,
date: Math.floor(Date.now() / 1000), date: (noLocalTimeUpdate && draft.date) || getServerTime(),
isLocal: true, isLocal: true,
} : undefined; } : undefined;
global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft); global = replaceThreadLocalStateParam(global, chatId, threadId, 'draft', newDraft);
if (!noLocalTimeUpdate) {
global = updateChat(global, chatId, { draftDate: newDraft?.date });
}
setGlobal(global); setGlobal(global);
@ -976,8 +988,7 @@ async function saveDraft<T extends GlobalState>({
} }
global = getGlobal(); global = getGlobal();
global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft); global = replaceThreadLocalStateParam(global, chatId, threadId, 'draft', newDraft);
global = updateChat(global, chatId, { draftDate: newDraft?.date });
setGlobal(global); setGlobal(global);
} }
@ -985,7 +996,7 @@ async function saveDraft<T extends GlobalState>({
addActionHandler('toggleMessageWebPage', (global, actions, payload): ActionReturnType => { addActionHandler('toggleMessageWebPage', (global, actions, payload): ActionReturnType => {
const { chatId, threadId, noWebPage } = payload; 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 => { addActionHandler('pinMessage', (global, actions, payload): ActionReturnType => {
@ -1017,7 +1028,7 @@ addActionHandler('unpinAllMessages', async (global, actions, payload): Promise<v
pinnedIds?.forEach((id) => { pinnedIds?.forEach((id) => {
global = updateChatMessage(global, chatId, id, { isPinned: false }); 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); setGlobal(global);
}); });
@ -1287,16 +1298,9 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn
const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const viewportIds = selectViewportIds(global, chatId, threadId, tabId);
const minId = selectFirstUnreadId(global, chatId, threadId); 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) { if (!viewportIds || !minId || !threadReadState?.unreadCount) {
global = updateThreadInfo(global, chatId, threadId, {
lastReadInboxMessageId: maxId,
});
return global;
}
if (!viewportIds || !minId || (!chat.unreadCount && !topic?.unreadCount)) {
return global; return global;
} }
@ -1305,42 +1309,21 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn
return global; return global;
} }
if (chat.isForum && topic) { const newUnreadCount = Math.max(0, (threadReadState.unreadCount || 0) - readCount);
global = updateThreadInfo(global, chatId, threadId, { global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadCount', newUnreadCount);
lastReadInboxMessageId: maxId, global = replaceThreadReadStateParam(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),
});
}
return updateTopic(global, chatId, Number(threadId), { return global;
unreadCount: newTopicUnreadCount,
});
}
return updateChat(global, chatId, {
lastReadInboxMessageId: maxId,
unreadCount: Math.max(0, (chat.unreadCount || 0) - readCount),
});
}); });
addActionHandler('markMessagesRead', (global, actions, payload): ActionReturnType => { addActionHandler('markMessagesRead', (global, actions, payload): ActionReturnType => {
const { messageIds, tabId = getCurrentTabId(), shouldFetchUnreadReactions } = payload; const { chatId, messageIds } = payload;
const chat = selectChat(global, chatId);
const chat = selectCurrentChat(global, tabId);
if (!chat) { if (!chat) {
return; return;
} }
void callApi('markMessagesRead', { chat, messageIds }) void callApi('markMessagesRead', { chat, messageIds });
.then(() => {
if (shouldFetchUnreadReactions) {
actions.fetchUnreadReactions({ chatId: chat.id });
}
});
}); });
addActionHandler('loadWebPagePreview', async (global, actions, payload): Promise<void> => { addActionHandler('loadWebPagePreview', async (global, actions, payload): Promise<void> => {
@ -1530,24 +1513,17 @@ addActionHandler('loadScheduledHistory', async (global, actions, payload): Promi
global = getGlobal(); global = getGlobal();
global = updateScheduledMessages(global, chat.id, byId); 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) { if (!ids.length) {
global = updatePeerFullInfo(global, chat.id, { hasScheduledMessages: false }); global = updatePeerFullInfo(global, chat.id, { hasScheduledMessages: false });
} }
if (chat?.isForum) { Object.entries(idsByThreadId).forEach(([tId, newThreadScheduledIds]) => {
const scheduledPerThread: Record<ThreadId, number[]> = {}; const threadId = tId as ThreadId;
messages.forEach((message) => { if (!chat.isForum && threadId !== MAIN_THREAD_ID) return;
const threadId = selectThreadIdFromMessage(global, message); global = replaceThreadLocalStateParam(global, chat.id, threadId, 'scheduledIds', newThreadScheduledIds);
const scheduledInThread = scheduledPerThread[threadId] || []; });
scheduledInThread.push(message.id);
scheduledPerThread[threadId] = scheduledInThread;
});
Object.entries(scheduledPerThread).forEach(([threadId, scheduledIds]) => {
global = replaceThreadParam(global, chat.id, Number(threadId), 'scheduledIds', scheduledIds);
});
}
setGlobal(global); setGlobal(global);
}); });
@ -1798,12 +1774,12 @@ async function loadViewportMessages<T extends GlobalState>(
} }
const { const {
messages, count, messages, count, topics,
} = result; } = result;
global = getGlobal(); global = getGlobal();
const localTypingDrafts = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const localTypingDrafts = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId');
const typingDraftMessages = localTypingDrafts ? Object.values(localTypingDrafts) const typingDraftMessages = localTypingDrafts ? Object.values(localTypingDrafts)
.map((id) => selectChatMessage(global, chatId, id)) .map((id) => selectChatMessage(global, chatId, id))
.filter(Boolean) : []; .filter(Boolean) : [];
@ -1846,45 +1822,18 @@ async function loadViewportMessages<T extends GlobalState>(
} }
} }
if (count) { if (count !== undefined) {
global = updateThreadInfo(global, chat.id, threadId, { global = updateThreadInfoMessagesCount(global, chat.id, threadId, count);
messagesCount: count,
});
} }
topics.forEach((topicState) => {
global = updateTopicWithState(global, chat.id, topicState);
});
setGlobal(global); setGlobal(global);
onLoaded?.(); onLoaded?.();
} }
async function loadMessage<T extends GlobalState>(
global: T, chat: ApiChat, messageId: number, replyOriginForId?: number,
) {
const result = await callApi('fetchMessage', { chat, messageId });
if (!result) {
return undefined;
}
if (result === MESSAGE_DELETED) {
if (replyOriginForId) {
global = getGlobal();
const replyMessage = selectChatMessage(global, chat.id, replyOriginForId);
global = updateChatMessage(global, chat.id, replyOriginForId, {
...replyMessage,
replyInfo: undefined,
});
setGlobal(global);
}
return undefined;
}
global = getGlobal();
global = updateChatMessage(global, chat.id, messageId, result.message);
setGlobal(global);
return result.message;
}
function findClosestIndex(sourceIds: number[], offsetId: number) { function findClosestIndex(sourceIds: number[], offsetId: number) {
if (offsetId < sourceIds[0]) { if (offsetId < sourceIds[0]) {
return 0; return 0;
@ -2297,9 +2246,38 @@ addActionHandler('hideSponsored', async (global, actions, payload): Promise<void
}); });
}); });
addActionHandler('fetchUnreadMentions', async (global, actions, payload): Promise<void> => { addActionHandler('loadUnreadMentions', async (global, actions, payload): Promise<void> => {
const { chatId, offsetId } = payload; const { chatId, threadId = MAIN_THREAD_ID, offsetId } = payload;
await fetchUnreadMentions(global, chatId, offsetId);
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<void> => { addActionHandler('approveSuggestedPost', async (global, actions, payload): Promise<void> => {
@ -2373,53 +2351,35 @@ addActionHandler('rejectSuggestedPost', async (global, actions, payload): Promis
}); });
}); });
async function fetchUnreadMentions<T extends GlobalState>(global: T, chatId: string, offsetId?: number) {
const chat = selectChat(global, chatId);
if (!chat) return;
const result = await callApi('fetchUnreadMentions', { chat, offsetId });
if (!result) return;
const { messages } = result;
const byId = buildCollectionByKey(messages, 'id');
const ids = Object.keys(byId).map(Number);
global = getGlobal();
global = addChatMessagesById(global, chat.id, byId);
global = addUnreadMentions(global, chatId, chat, ids);
setGlobal(global);
}
addActionHandler('markMentionsRead', (global, actions, payload): ActionReturnType => { addActionHandler('markMentionsRead', (global, actions, payload): ActionReturnType => {
const { chatId, messageIds, tabId = getCurrentTabId() } = payload; const { chatId, messageIds } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat) return; if (!chat) return;
global = removeUnreadMentions(global, chatId, chat, messageIds, true); global = removeUnreadMentions({
global,
chatId,
ids: messageIds,
});
setGlobal(global); setGlobal(global);
actions.markMessagesRead({ messageIds, tabId }); actions.markMessagesRead({ chatId, messageIds });
}); });
addActionHandler('focusNextMention', async (global, actions, payload): Promise<void> => { addActionHandler('focusNextMention', async (global, actions, payload): Promise<void> => {
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(); global = getGlobal();
const previousChatId = chat.id; readState = selectThreadReadState(global, chatId, threadId);
chat = selectCurrentChat(global, tabId); if (!readState?.unreadMentions) return;
if (!chat?.unreadMentions || previousChatId !== chat.id) 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 => { addActionHandler('readAllMentions', (global, actions, payload): ActionReturnType => {
@ -2428,17 +2388,13 @@ addActionHandler('readAllMentions', (global, actions, payload): ActionReturnType
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat) return undefined; 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) { global = updateThreadReadState(global, chatId, threadId, {
return updateChat(global, chat.id, { unreadMentionsCount: 0,
unreadMentionsCount: undefined, unreadMentions: undefined,
unreadMentions: undefined, });
}); return global;
}
// TODO[Forums]: Support mentions in threads
return undefined;
}); });
addActionHandler('openUrl', (global, actions, payload): ActionReturnType => { addActionHandler('openUrl', (global, actions, payload): ActionReturnType => {
@ -2805,7 +2761,7 @@ addActionHandler('loadMessageViews', async (global, actions, payload): Promise<v
}, true); }, true);
if (update.threadInfo) { if (update.threadInfo) {
global = updateThreadInfo(global, chatId, update.id, update.threadInfo); global = updateThreadInfo(global, update.threadInfo);
} }
}); });

View File

@ -5,7 +5,7 @@ import { ApiMediaFormat, MAIN_THREAD_ID } from '../../../api/types';
import { GENERAL_REFETCH_INTERVAL } from '../../../config'; import { GENERAL_REFETCH_INTERVAL } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { import {
buildCollectionByCallback, buildCollectionByKey, omit, partition, unique, buildCollectionByCallback, buildCollectionByKey, omit, partition,
} from '../../../util/iteratees'; } from '../../../util/iteratees';
import { getMessageKey } from '../../../util/keys/messageKey'; import { getMessageKey } from '../../../util/keys/messageKey';
import * as mediaLoader from '../../../util/mediaLoader'; import * as mediaLoader from '../../../util/mediaLoader';
@ -19,16 +19,23 @@ import {
isMessageLocal, isMessageLocal,
isSameReaction, isSameReaction,
} from '../../helpers'; } from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, getPromiseActions, setGlobal } from '../../index';
import { import {
addChatMessagesById, updateChat, updateChatMessage, addChatMessagesById,
updateChatMessage,
updateTopicWithState,
} from '../../reducers'; } from '../../reducers';
import { addMessageReaction, subtractXForEmojiInteraction, updateUnreadReactions } from '../../reducers/reactions'; import {
addMessageReaction,
addUnreadReactions,
removeUnreadReactions,
subtractXForEmojiInteraction,
} from '../../reducers/reactions';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { updateThreadReadState } from '../../reducers/threads';
import { import {
selectChat, selectChat,
selectChatMessage, selectChatMessage,
selectCurrentChat,
selectDefaultReaction, selectDefaultReaction,
selectIsChatWithSelf, selectIsChatWithSelf,
selectIsCurrentUserFrozen, selectIsCurrentUserFrozen,
@ -37,6 +44,7 @@ import {
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectTabState, selectTabState,
} from '../../selectors'; } from '../../selectors';
import { selectThreadReadState } from '../../selectors/threads';
const INTERACTION_RANDOM_OFFSET = 40; const INTERACTION_RANDOM_OFFSET = 40;
@ -467,98 +475,82 @@ addActionHandler('sendWatchingEmojiInteraction', (global, actions, payload): Act
}, tabId); }, tabId);
}); });
addActionHandler('fetchUnreadReactions', async (global, actions, payload): Promise<void> => { addActionHandler('loadUnreadReactions', async (global, actions, payload): Promise<void> => {
const { chatId, offsetId } = payload; const { chatId, threadId = MAIN_THREAD_ID, offsetId } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat) return; 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) return;
if (!result || !result.messages.length) {
global = getGlobal();
global = updateUnreadReactions(global, chatId, {
unreadReactionsCount: 0,
});
setGlobal(global); const { messages, topics, totalCount } = result;
return;
}
const { messages } = result;
const byId = buildCollectionByKey(messages, 'id'); const byId = buildCollectionByKey(messages, 'id');
const ids = Object.keys(byId).map(Number); const ids = Object.keys(byId).map(Number);
global = getGlobal(); global = getGlobal();
global = addChatMessagesById(global, chat.id, byId); global = addChatMessagesById(global, chat.id, byId);
global = updateUnreadReactions(global, chatId, { topics.forEach((topicState) => {
unreadReactions: unique([...(chat.unreadReactions || []), ...ids]).sort((a, b) => b - a), global = updateTopicWithState(global, chat.id, topicState);
});
global = addUnreadReactions({
global,
chatId,
ids,
totalCount,
}); });
setGlobal(global); setGlobal(global);
}); });
addActionHandler('animateUnreadReaction', (global, actions, payload): ActionReturnType => { addActionHandler('animateUnreadReaction', (global, actions, payload): ActionReturnType => {
const { messageIds, tabId = getCurrentTabId() } = payload; const { chatId, messageIds, tabId = getCurrentTabId() } = payload;
const chat = selectCurrentChat(global, tabId); global = removeUnreadReactions({ global, chatId, ids: messageIds });
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,
});
setGlobal(global); setGlobal(global);
actions.markMessagesRead({ messageIds, shouldFetchUnreadReactions: true, tabId }); actions.markMessagesRead({ chatId, messageIds });
if (!selectPerformanceSettingsValue(global, 'reactionEffects')) return undefined; if (!selectPerformanceSettingsValue(global, 'reactionEffects')) return undefined;
global = getGlobal(); global = getGlobal();
messageIds.forEach((id) => { messageIds.forEach((id) => {
const message = selectChatMessage(global, chat.id, id); const message = selectChatMessage(global, chatId, id);
if (!message) return; if (!message) return;
const { reaction, isOwn, isUnread } = message.reactions?.recentReactions?.[0] ?? {}; const { reaction, isOwn, isUnread } = message.reactions?.recentReactions?.[0] ?? {};
if (reaction && isUnread && !isOwn) { if (reaction && isUnread && !isOwn) {
const messageKey = getMessageKey(message); const messageKey = getMessageKey(message);
actions.startActiveReaction({ containerId: messageKey, reaction, tabId: getCurrentTabId() }); actions.startActiveReaction({ containerId: messageKey, reaction, tabId });
} }
}); });
return undefined; return undefined;
}); });
addActionHandler('focusNextReaction', (global, actions, payload): ActionReturnType => { addActionHandler('focusNextReaction', async (global, actions, payload): Promise<void> => {
const { tabId = getCurrentTabId() } = payload || {}; const { chatId, threadId = MAIN_THREAD_ID, tabId = getCurrentTabId() } = payload || {};
const chat = selectCurrentChat(global, tabId); let readState = selectThreadReadState(global, chatId, threadId);
if (!chat?.unreadReactions) { if (!readState?.unreadReactions?.length) {
if (chat?.unreadReactionsCount) { await getPromiseActions().loadUnreadReactions({ chatId, threadId });
return updateChat(global, chat.id, {
unreadReactionsCount: 0, global = getGlobal();
}); readState = selectThreadReadState(global, chatId, threadId);
} if (!readState?.unreadReactions?.length) return;
return undefined;
} }
actions.focusMessage({ 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 }); actions.markMessagesRead({ chatId, messageIds: [readState.unreadReactions[0]] });
return undefined;
}); });
addActionHandler('readAllReactions', (global, actions, payload): ActionReturnType => { addActionHandler('readAllReactions', (global, actions, payload): ActionReturnType => {
@ -566,17 +558,13 @@ addActionHandler('readAllReactions', (global, actions, payload): ActionReturnTyp
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat) return undefined; 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) { global = updateThreadReadState(global, chatId, threadId, {
return updateUnreadReactions(global, chat.id, { unreadReactionsCount: 0,
unreadReactionsCount: undefined, unreadReactions: undefined,
unreadReactions: undefined, });
}); return global;
}
// TODO[Forums]: Support unread reactions in threads
return undefined;
}); });
addActionHandler('loadTopReactions', async (global): Promise<void> => { addActionHandler('loadTopReactions', async (global): Promise<void> => {

View File

@ -1,7 +1,6 @@
import { addCallback } from '../../../lib/teact/teactn'; import { addCallback } from '../../../lib/teact/teactn';
import type { ApiThreadInfo } from '../../../api/types/messages'; import type { ThreadId, ThreadLocalState } from '../../../types';
import type { Thread, ThreadId } from '../../../types';
import type { RequiredGlobalActions } from '../../index'; import type { RequiredGlobalActions } from '../../index';
import type { ActionReturnType, GlobalState } from '../../types'; import type { ActionReturnType, GlobalState } from '../../types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
@ -18,27 +17,28 @@ import {
} from '../../index'; } from '../../index';
import { import {
addChatMessagesById, addChatMessagesById,
addMessages,
safeReplaceViewportIds, safeReplaceViewportIds,
updateChats, updateChats,
updateListedIds, updateListedIds,
updateThread,
updateThreadInfo,
updateUsers, updateUsers,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { updateThreadInfo, updateThreadLocalState } from '../../reducers/threads';
import { import {
selectChat, selectChat,
selectChatMessage, selectChatMessage,
selectChatMessages, selectChatMessages,
selectCurrentMessageList, selectCurrentMessageList,
selectTabState,
selectTopics,
} from '../../selectors';
import {
selectDraft, selectDraft,
selectEditingDraft, selectEditingDraft,
selectEditingId, selectEditingId,
selectTabState,
selectThreadInfo, selectThreadInfo,
selectTopics, selectThreadReadState,
} from '../../selectors'; } from '../../selectors/threads';
const RELEASE_STATUS_TIMEOUT = 15000; // 15 sec; const RELEASE_STATUS_TIMEOUT = 15000; // 15 sec;
@ -106,48 +106,26 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
// Memoize drafts // Memoize drafts
const draftChatIds = Object.keys(global.messages.byChatId); const draftChatIds = Object.keys(global.messages.byChatId);
const draftsByChatId = draftChatIds.reduce<Record<string, Record<number, Partial<Thread>>>>((acc, chatId) => { const draftsByChatId = draftChatIds
acc[chatId] = Object .reduce<Record<string, Record<number, Partial<ThreadLocalState>>>>((acc, chatId) => {
.keys(global.messages.byChatId[chatId].threadsById) acc[chatId] = Object
.reduce<Record<number, Partial<Thread>>>((acc2, threadId) => { .keys(global.messages.byChatId[chatId].threadsById)
acc2[Number(threadId)] = omitUndefined({ .reduce<Record<number, Partial<ThreadLocalState>>>((acc2, threadId) => {
draft: selectDraft(global, chatId, Number(threadId)), acc2[Number(threadId)] = omitUndefined({
editingId: selectEditingId(global, chatId, Number(threadId)), draft: selectDraft(global, chatId, Number(threadId)),
editingDraft: selectEditingDraft(global, chatId, Number(threadId)), editingId: selectEditingId(global, chatId, Number(threadId)),
}); editingDraft: selectEditingDraft(global, chatId, Number(threadId)),
});
return acc2; return acc2;
}, {}); }, {});
return acc; 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,
});
}
});
for (const { id: tabId } of Object.values(global.byTabId)) { for (const { id: tabId } of Object.values(global.byTabId)) {
global = getGlobal(); global = getGlobal();
const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {}; const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {};
const activeThreadId = currentThreadId || MAIN_THREAD_ID; const activeThreadId = currentThreadId || MAIN_THREAD_ID;
const threadInfo = currentChatId && currentThreadId
? selectThreadInfo(global, currentChatId, currentThreadId) : undefined;
const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined; const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined;
if (currentChatId && currentChat) { if (currentChatId && currentChat) {
const [result, resultDiscussion] = await Promise.all([ const [result, resultDiscussion] = await Promise.all([
@ -173,12 +151,13 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
: []; : [];
const topics = selectTopics(global, currentChatId); const topics = selectTopics(global, currentChatId);
const topicLastMessages = topics ? Object.values(topics) const topicLastMessages = topics ? Object.values(topics)
.map(({ lastMessageId }) => currentChatMessages[lastMessageId]) .map(({ id }) => {
.filter(Boolean) const topicThreadInfo = selectThreadInfo(global, currentChatId, id);
: []; return topicThreadInfo?.lastMessageId ? currentChatMessages[topicThreadInfo.lastMessageId] : undefined;
}).filter(Boolean) : [];
const resultMessageIds = result.messages.map(({ id }) => id); 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 const isDiscussionStartLoaded = !result.messages.length
|| result.messages.some(({ id }) => id === resultDiscussion?.firstMessageId); || result.messages.some(({ id }) => id === resultDiscussion?.firstMessageId);
@ -189,13 +168,7 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
const listedIds = unique(allMessages.map(({ id }) => id)); const listedIds = unique(allMessages.map(({ id }) => id));
if (!wasReset) { if (!wasReset) {
global = { global = resetMessages(global);
...global,
messages: {
...global.messages,
byChatId: {},
},
};
Object.values(global.byTabId).forEach(({ id: otherTabId }) => { Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
global = updateTabState(global, { global = updateTabState(global, {
@ -208,17 +181,11 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
global = addChatMessagesById(global, currentChatId, byId); global = addChatMessagesById(global, currentChatId, byId);
global = updateListedIds(global, currentChatId, activeThreadId, listedIds); global = updateListedIds(global, currentChatId, activeThreadId, listedIds);
Object.entries(messagesThreadInfos).forEach(([id, thread]) => { Object.entries(messagesThreads).forEach(([id, thread]) => {
if (!thread?.threadInfo) return; 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 }) => { Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
const { chatId: otherChatId, threadId: otherThreadId } = selectCurrentMessageList(global, otherTabId) || {}; const { chatId: otherChatId, threadId: otherThreadId } = selectCurrentMessageList(global, otherTabId) || {};
if (otherChatId === currentChatId && otherThreadId === activeThreadId) { if (otherChatId === currentChatId && otherThreadId === activeThreadId) {
@ -247,13 +214,7 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
global = getGlobal(); global = getGlobal();
if (!areMessagesLoaded) { if (!areMessagesLoaded) {
global = { global = resetMessages(global);
...global,
messages: {
...global.messages,
byChatId: {},
},
};
Object.values(global.byTabId).forEach(({ id: otherTabId }) => { Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
global = updateTabState(global, { global = updateTabState(global, {
@ -263,26 +224,13 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
} }
// Restore drafts // Restore drafts
Object.keys(draftsByChatId).forEach((chatId) => { Object.keys(draftsByChatId).forEach((chatId) => {
const threads = draftsByChatId[chatId]; const threads = draftsByChatId[chatId];
Object.keys(threads).forEach((threadId) => { 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); setGlobal(global);
Object.values(global.byTabId).forEach(({ id: tabId }) => { Object.values(global.byTabId).forEach(({ id: tabId }) => {
@ -293,17 +241,28 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
}); });
} }
function resetMessages<T extends GlobalState>(global: T) {
return {
...global,
messages: {
...global.messages,
byChatId: {},
},
};
}
function loadTopMessages<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { function loadTopMessages<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
const currentUserId = global.currentUserId!; const currentUserId = global.currentUserId!;
const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId);
const realChatId = isSavedDialog ? String(threadId) : chatId; const realChatId = isSavedDialog ? String(threadId) : chatId;
const chat = selectChat(global, realChatId)!; const chat = selectChat(global, realChatId)!;
const readState = selectThreadReadState(global, chatId, threadId);
return callApi('fetchMessages', { return callApi('fetchMessages', {
chat, chat,
threadId, threadId,
offsetId: !isSavedDialog ? chat.lastReadInboxMessageId : undefined, offsetId: !isSavedDialog ? readState?.lastReadInboxMessageId : undefined,
addOffset: -(Math.round(MESSAGE_LIST_SLICE / 2) + 1), addOffset: -(Math.round(MESSAGE_LIST_SLICE / 2) + 1),
limit: MESSAGE_LIST_SLICE, limit: MESSAGE_LIST_SLICE,
isSavedDialog, isSavedDialog,

View File

@ -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 type { ActionReturnType } from '../../types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
@ -20,16 +20,15 @@ import {
replaceChatMessages, replaceChatMessages,
replacePeerPhotos, replacePeerPhotos,
replacePinnedTopicIds, replacePinnedTopicIds,
replaceThreadParam,
updateChat, updateChat,
updateChatFullInfo, updateChatFullInfo,
updateChatListType, updateChatListType,
updatePeerStoriesHidden, updatePeerStoriesHidden,
updateThreadInfo,
updateTopic, updateTopic,
} from '../../reducers'; } from '../../reducers';
import { updateUnreadReactions } from '../../reducers/reactions'; import { removeUnreadReactions } from '../../reducers/reactions';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { addUnreadMessageToCounter, replaceThreadLocalStateParam } from '../../reducers/threads';
import { import {
selectChat, selectChat,
selectChatFullInfo, selectChatFullInfo,
@ -40,9 +39,8 @@ import {
selectIsChatListed, selectIsChatListed,
selectPeer, selectPeer,
selectTabState, selectTabState,
selectThreadParam,
selectTopicFromMessage,
} from '../../selectors'; } from '../../selectors';
import { selectThreadLocalStateParam, selectThreadReadState } from '../../selectors/threads';
const TYPING_STATUS_CLEAR_DELAY = 6000; // 6 seconds const TYPING_STATUS_CLEAR_DELAY = 6000; // 6 seconds
const INVALIDATE_FULL_CHAT_FIELDS = new Set<keyof ApiChat>([ const INVALIDATE_FULL_CHAT_FIELDS = new Set<keyof ApiChat>([
@ -54,16 +52,16 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
switch (update['@type']) { switch (update['@type']) {
case 'updateChat': { case 'updateChat': {
const localChat = selectChat(global, update.id); const localChat = selectChat(global, update.id);
const { isForum: prevIsForum, lastReadOutboxMessageId } = localChat || {}; const localReadState = selectThreadReadState(global, update.id, MAIN_THREAD_ID);
const { isForum: prevIsForum } = localChat || {};
if (update.chat.lastReadOutboxMessageId && lastReadOutboxMessageId const { lastReadOutboxMessageId } = localReadState || {};
&& update.chat.lastReadOutboxMessageId < lastReadOutboxMessageId) { if (update.readState?.lastReadOutboxMessageId && lastReadOutboxMessageId
&& update.readState.lastReadOutboxMessageId < lastReadOutboxMessageId) {
update = { update = {
...update, ...update,
chat: omit(update.chat, ['lastReadInboxMessageId']), readState: omit(update.readState, ['lastReadOutboxMessageId']),
}; };
} }
global = updateChat(global, update.id, update.chat); global = updateChat(global, update.id, update.chat);
if (localChat?.areStoriesHidden !== update.chat.areStoriesHidden) { if (localChat?.areStoriesHidden !== update.chat.areStoriesHidden) {
@ -91,7 +89,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
if (update.chat.id) { if (update.chat.id) {
closeMessageNotifications({ closeMessageNotifications({
chatId: update.chat.id, chatId: update.chat.id,
lastReadInboxMessageId: update.chat.lastReadInboxMessageId, lastReadInboxMessageId: update.readState?.lastReadInboxMessageId,
}); });
} }
@ -159,34 +157,16 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
return global; 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': { case 'updateChatTypingStatus': {
const { id, threadId = MAIN_THREAD_ID, typingStatus } = update; const { id, threadId = MAIN_THREAD_ID, typingStatus } = update;
global = replaceThreadParam(global, id, threadId, 'typingStatus', typingStatus); global = replaceThreadLocalStateParam(global, id, threadId, 'typingStatus', typingStatus);
setGlobal(global); setGlobal(global);
setTimeout(() => { setTimeout(() => {
global = getGlobal(); global = getGlobal();
const currentTypingStatus = selectThreadParam(global, id, threadId, 'typingStatus'); const currentTypingStatus = selectThreadLocalStateParam(global, id, threadId, 'typingStatus');
if (typingStatus && currentTypingStatus && typingStatus.timestamp === currentTypingStatus.timestamp) { if (typingStatus && currentTypingStatus && typingStatus.timestamp === currentTypingStatus.timestamp) {
global = replaceThreadParam(global, id, threadId, 'typingStatus', undefined); global = replaceThreadLocalStateParam(global, id, threadId, 'typingStatus', undefined);
setGlobal(global); setGlobal(global);
} }
}, TYPING_STATUS_CLEAR_DELAY); }, TYPING_STATUS_CLEAR_DELAY);
@ -195,39 +175,32 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
case 'newMessage': { case 'newMessage': {
const { message } = update; const { chatId, id, message } = update;
const isOur = message.senderId ? message.senderId === global.currentUserId : message.isOutgoing; const isOur = message.senderId ? message.senderId === global.currentUserId : message.isOutgoing;
if (isOur && !message.isFromScheduled) { if (isOur && !message.isFromScheduled) {
return undefined; return undefined;
} }
const isLocal = isLocalMessageId(message.id!); const isLocal = isLocalMessageId(id);
const chat = selectChat(global, update.chatId); const chat = selectChat(global, chatId);
if (!chat) { if (!chat) {
return undefined; return undefined;
} }
const hasMention = Boolean(update.message.id && update.message.hasUnreadMention); const hasMention = Boolean(message.hasUnreadMention);
if (!isLocal || chat.id === SERVICE_NOTIFICATIONS_USER_ID) { if (!isLocal || chat.id === SERVICE_NOTIFICATIONS_USER_ID) {
global = updateChat(global, update.chatId, { global = addUnreadMessageToCounter(global, chatId, message);
unreadCount: chat.unreadCount ? chat.unreadCount + 1 : 1,
});
if (hasMention) { if (hasMention) {
global = addUnreadMentions(global, update.chatId, chat, [update.message.id!], true); global = addUnreadMentions({
} global,
chatId,
const topic = chat.isForum ? selectTopicFromMessage(global, message as ApiMessage) : undefined; ids: [id],
if (topic) {
global = updateTopic(global, update.chatId, topic.id, {
unreadCount: topic.unreadCount ? topic.unreadCount + 1 : 1,
}); });
} }
// TODO Replace draft with new message
} }
setGlobal(global); setGlobal(global);
@ -246,18 +219,17 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
ids.forEach((id) => { ids.forEach((id) => {
const chatId = ('channelId' in update ? update.channelId : selectCommonBoxChatId(global, id))!; const chatId = ('channelId' in update ? update.channelId : selectCommonBoxChatId(global, id))!;
const chat = selectChat(global, chatId);
if (messageUpdate.reactions && chat?.unreadReactionsCount if (messageUpdate.reactions && !checkIfHasUnreadReactions(global, messageUpdate.reactions)) {
&& !checkIfHasUnreadReactions(global, messageUpdate.reactions)) { global = removeUnreadReactions({ global, chatId, ids: [id] });
global = updateUnreadReactions(global, chatId, {
unreadReactionsCount: Math.max(chat.unreadReactionsCount - 1, 0) || undefined,
unreadReactions: chat.unreadReactions?.filter((i) => i !== id),
});
} }
if (!messageUpdate.hasUnreadMention && chat?.unreadMentionsCount) { if (!messageUpdate.hasUnreadMention) {
global = removeUnreadMentions(global, chatId, chat, [id], true); global = removeUnreadMentions({
global,
chatId,
ids: [id],
});
} }
}); });
@ -481,8 +453,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
return undefined; return undefined;
} }
global = replaceThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'draft', draft); global = replaceThreadLocalStateParam(global, chatId, threadId || MAIN_THREAD_ID, 'draft', draft);
global = updateChat(global, chatId, { draftDate: draft?.date });
return global; return global;
} }

View File

@ -1,5 +1,5 @@
import type { import type {
ApiChat, ApiMediaExtendedPreview, ApiMessage, ApiReactions, ApiMediaExtendedPreview, ApiMessage, ApiReactions,
MediaContent, MediaContent,
} from '../../../api/types'; } from '../../../api/types';
import type { ActiveEmojiInteraction, ThreadId } from '../../../types'; import type { ActiveEmojiInteraction, ThreadId } from '../../../types';
@ -14,7 +14,7 @@ import { areDeepEqual } from '../../../util/areDeepEqual';
import { isUserId } from '../../../util/entities/ids'; import { isUserId } from '../../../util/entities/ids';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { import {
buildCollectionByKey, omit, pickTruthy, unique, buildCollectionByKey, omit, unique,
} from '../../../util/iteratees'; } from '../../../util/iteratees';
import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey'; import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey';
import { notifyAboutMessage } from '../../../util/notifications'; import { notifyAboutMessage } from '../../../util/notifications';
@ -28,6 +28,7 @@ import {
getIsSavedDialog, getIsSavedDialog,
getMessageContent, getMessageContent,
getMessageText, getMessageText,
groupMessageIdsByThreadId,
isActionMessage, isActionMessage,
isMessageLocal, isMessageLocal,
} from '../../helpers'; } from '../../helpers';
@ -49,9 +50,7 @@ import {
deleteQuickReplyMessages, deleteQuickReplyMessages,
deleteTopic, deleteTopic,
removeChatFromChatLists, removeChatFromChatLists,
replaceThreadParam,
replaceWebPage, replaceWebPage,
updateChat,
updateChatLastMessageId, updateChatLastMessageId,
updateChatMediaLoadingState, updateChatMediaLoadingState,
updateChatMessage, updateChatMessage,
@ -63,13 +62,17 @@ import {
updateQuickReplies, updateQuickReplies,
updateQuickReplyMessage, updateQuickReplyMessage,
updateScheduledMessage, updateScheduledMessage,
updateThreadInfo,
updateThreadInfos,
updateThreadUnreadFromForwardedMessage,
updateTopic,
} from '../../reducers'; } from '../../reducers';
import { updateUnreadReactions } from '../../reducers/reactions'; import { addUnreadReactions, removeUnreadReactions } from '../../reducers/reactions';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import {
replaceThreadLocalStateParam,
replaceThreadReadStateParam,
updateThreadInfo,
updateThreadInfoLastMessageId,
updateThreadInfoMessagesCount,
updateThreadReadState,
} from '../../reducers/threads';
import { import {
selectCanAnimateSnapEffect, selectCanAnimateSnapEffect,
selectChat, selectChat,
@ -88,19 +91,22 @@ import {
selectListedIds, selectListedIds,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectPinnedIds, selectPinnedIds,
selectSavedDialogIdFromMessage,
selectScheduledIds, selectScheduledIds,
selectScheduledMessage, selectScheduledMessage,
selectTabState, selectTabState,
selectThread,
selectThreadByMessage,
selectThreadIdFromMessage,
selectThreadInfo,
selectThreadParam,
selectTopic, selectTopic,
selectTopicFromMessage, selectTopicFromMessage,
selectViewportIds, selectViewportIds,
} from '../../selectors'; } from '../../selectors';
import {
selectSavedDialogIdFromMessage,
selectThread,
selectThreadByMessage,
selectThreadIdFromMessage,
selectThreadInfo,
selectThreadLocalStateParam,
selectThreadReadState,
} from '../../selectors/threads';
const ANIMATION_DELAY = 350; const ANIMATION_DELAY = 350;
const SNAP_ANIMATION_DELAY = 1000; const SNAP_ANIMATION_DELAY = 1000;
@ -114,7 +120,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
chatId, id, message, shouldForceReply, wasDrafted, poll, webPage, chatId, id, message, shouldForceReply, wasDrafted, poll, webPage,
} = update; } = update;
global = updateWithLocalMedia(global, chatId, id, true, message); 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 newMessage = selectChatMessage(global, chatId, id)!;
const replyInfo = getMessageReplyInfo(newMessage); const replyInfo = getMessageReplyInfo(newMessage);
@ -127,7 +133,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.loadTopicById({ chatId, topicId: replyInfo.replyToMsgId }); actions.loadTopicById({ chatId, topicId: replyInfo.replyToMsgId });
} }
const isLocal = isMessageLocal(message as ApiMessage); const isLocal = isMessageLocal(message);
Object.values(global.byTabId).forEach(({ id: tabId }) => { Object.values(global.byTabId).forEach(({ id: tabId }) => {
// Force update for last message on drafted messages to prevent flickering // 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); const threadId = selectThreadIdFromMessage(global, newMessage);
global = updateChatMediaLoadingState(global, newMessage, chatId, threadId, tabId); 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 if (isLocal && message.isOutgoing && !(message.content?.action) && !storyReplyInfo?.storyId
&& !message.content?.storyData) { && !message.content?.storyData) {
const currentMessageList = selectCurrentMessageList(global, tabId); const currentMessageList = selectCurrentMessageList(global, tabId);
@ -147,7 +153,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.focusMessage({ actions.focusMessage({
chatId, chatId,
threadId: currentMessageList.threadId, threadId: currentMessageList.threadId,
messageId: message.id!, messageId: message.id,
noHighlight: true, noHighlight: true,
isResizingContainer: true, isResizingContainer: true,
tabId, tabId,
@ -189,10 +195,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
if (chat?.isBotForum && !newMessage.isOutgoing && !isLocal) { if (chat?.isBotForum && !newMessage.isOutgoing && !isLocal) {
const threadId = selectThreadIdFromMessage(global, newMessage); const threadId = selectThreadIdFromMessage(global, newMessage);
const typingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId');
const localDraftIds = Object.values(typingDraftStore || {}); const localDraftIds = Object.values(typingDraftStore || {});
global = deleteChatMessages(global, chatId, localDraftIds); global = deleteChatMessages(global, chatId, localDraftIds);
global = replaceThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId', undefined); global = replaceThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId', undefined);
} }
setGlobal(global); setGlobal(global);
@ -256,12 +262,16 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
global = updateWithLocalMedia(global, chatId, id, true, message, true); global = updateWithLocalMedia(global, chatId, id, true, message, true);
const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || []; 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); const threadId = selectThreadIdFromMessage(global, message);
if (threadId !== MAIN_THREAD_ID) { if (threadId !== MAIN_THREAD_ID) {
const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || []; 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) { if (poll) {
@ -303,12 +313,14 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
global = updateWithLocalMedia(global, chatId, id, false, message, true); global = updateWithLocalMedia(global, chatId, id, false, message, true);
const ids = Object.keys(selectChatScheduledMessages(global, chatId) || {}).map(Number).sort((a, b) => b - a); 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); const threadId = selectThreadIdFromMessage(global, currentMessage);
if (threadId !== MAIN_THREAD_ID) { if (threadId !== MAIN_THREAD_ID) {
const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || []; 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) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.id, poll);
@ -325,13 +337,31 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
case 'updateMessage': { case 'updateMessage': {
const { const {
chatId, id, message, poll, webPage, isFromNew, shouldForceReply, chatId, id, message, poll, webPage, isFromNew, isFull, shouldForceReply,
} = update; } = update;
const currentMessage = selectChatMessage(global, chatId, id); 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 (!currentMessage) {
if (isFromNew) { if (isFromNew && isFull) {
actions.apiUpdate({ actions.apiUpdate({
'@type': 'newMessage', '@type': 'newMessage',
id: update.id, id: update.id,
@ -342,33 +372,21 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
shouldForceReply, shouldForceReply,
}); });
} }
// If update contains the full message, store it
if (update.isFull) {
global = addMessages(global, [update.message]);
}
setGlobal(global);
return; 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) { if (message.content?.text?.text !== currentMessage?.content?.text?.text) {
global = clearMessageTranslation(global, chatId, id); global = clearMessageTranslation(global, chatId, id);
global = clearMessageSummary(global, chatId, id); global = clearMessageSummary(global, chatId, id);
} }
if (poll) { global = updateWithLocalMedia(global, chatId, id, false, message);
global = updatePoll(global, poll.id, poll);
}
if (webPage) {
global = replaceWebPage(global, webPage.id, webPage);
}
setGlobal(global); setGlobal(global);
@ -465,7 +483,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
chatId, localId, message, poll, chatId, localId, message, poll,
} = update; } = update;
global = updateListedAndViewportIds(global, actions, message); global = updateListedAndViewportIds(global, message);
const currentMessage = selectChatMessage(global, chatId, localId); const currentMessage = selectChatMessage(global, chatId, localId);
@ -509,17 +527,12 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.markMessageListRead({ maxId: message.id, tabId }); actions.markMessageListRead({ maxId: message.id, tabId });
}); });
if (thread?.threadInfo?.threadId) { if (thread?.threadInfo?.threadId) {
global = replaceThreadParam(global, chatId, thread.threadInfo.threadId, 'threadInfo', { global = replaceThreadReadStateParam(
...thread.threadInfo, global, chatId, thread.threadInfo.threadId, 'lastReadInboxMessageId', message.id,
lastMessageId: message.id, );
lastReadInboxMessageId: message.id, global = updateThreadInfoLastMessageId(global, chatId, thread.threadInfo.threadId, message.id);
});
} }
global = updateChat(global, chatId, {
lastReadInboxMessageId: message.id,
});
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
// Reload dialogs if chat is not present in the list // Reload dialogs if chat is not present in the list
if (!chat?.isNotJoined && !selectIsChatListed(global, chatId)) { if (!chat?.isNotJoined && !selectIsChatListed(global, chatId)) {
@ -543,12 +556,16 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
chatId, localId, message, poll, chatId, localId, message, poll,
} = update; } = update;
const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || []; 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); const threadId = selectThreadIdFromMessage(global, message);
if (threadId !== MAIN_THREAD_ID) { if (threadId !== MAIN_THREAD_ID) {
const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || []; 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); const currentMessage = selectScheduledMessage(global, chatId, localId);
@ -572,27 +589,14 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
case 'updatePinnedIds': { case 'updatePinnedIds': {
const { chatId, isPinned, messageIds } = update; const { chatId, isPinned, messageIds } = update;
const messages = pickTruthy(selectChatMessages(global, chatId), messageIds); const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, messageIds, false);
const updatePerThread: Record<ThreadId, number[]> = {
[MAIN_THREAD_ID]: messageIds,
};
Object.values(messages).forEach((message) => {
const threadId = selectThreadIdFromMessage(global, message);
global = updateChatMessage(global, chatId, message.id, {
isPinned,
});
if (threadId === MAIN_THREAD_ID) return;
const currentUpdatedInThread = updatePerThread[threadId] || [];
currentUpdatedInThread.push(message.id);
updatePerThread[threadId] = currentUpdatedInThread;
});
Object.entries(updatePerThread).forEach(([threadId, ids]) => { Object.entries(messageIdsByThreadId).forEach(([threadId, ids]) => {
const pinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID) || []; const pinnedIds = selectPinnedIds(global, chatId, threadId) || [];
const newPinnedIds = isPinned const newPinnedIds = isPinned
? unique(pinnedIds.concat(ids)).sort((a, b) => b - a) ? unique(pinnedIds.concat(ids)).sort((a, b) => b - a)
: pinnedIds.filter((id) => !ids.includes(id)); : pinnedIds.filter((id) => !ids.includes(id));
global = replaceThreadParam(global, chatId, Number(threadId), 'pinnedIds', newPinnedIds); global = replaceThreadLocalStateParam(global, chatId, threadId, 'pinnedIds', newPinnedIds);
}); });
setGlobal(global); setGlobal(global);
@ -604,36 +608,18 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
threadInfo, threadInfo,
} = update; } = update;
global = updateThreadInfos(global, [threadInfo]); global = updateThreadInfo(global, threadInfo);
setGlobal(global); setGlobal(global);
global = getGlobal();
const { chatId, threadId } = threadInfo; break;
if (!chatId || !threadId) return; }
const chat = selectChat(global, chatId); case 'updateThreadReadState': {
const currentThreadInfo = selectThreadInfo(global, chatId, threadId); const {
const topic = selectTopic(global, chatId, threadId); chatId, threadId, readState,
if (chat?.isForum) { } = update;
if (!topic || topic.lastMessageId !== currentThreadInfo?.lastReadInboxMessageId) {
actions.loadTopicById({ chatId, topicId: Number(threadId) });
} else {
global = updateTopic(global, chatId, Number(threadId), {
unreadCount: 0,
});
}
}
// Update reply thread last read message id if already read in main thread global = updateThreadReadState(global, chatId, threadId, readState);
if (!chat?.isForum) {
const lastReadInboxMessageId = chat?.lastReadInboxMessageId;
const lastReadInboxMessageIdInThread = threadInfo.lastReadInboxMessageId || lastReadInboxMessageId;
if (lastReadInboxMessageId && lastReadInboxMessageIdInThread) {
global = updateThreadInfo(global, chatId, threadId, {
lastReadInboxMessageId: Math.max(lastReadInboxMessageIdInThread, lastReadInboxMessageId),
});
}
}
setGlobal(global); setGlobal(global);
break; break;
@ -803,13 +789,11 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
case 'updateMessageReactions': { case 'updateMessageReactions': {
const { chatId, id, reactions } = update; const { chatId, id, threadId, reactions } = update;
const message = selectChatMessage(global, chatId, id);
const chat = selectChat(global, update.chatId);
if (!chat || !message) return; global = updateReactions(global, actions, {
chatId, id, threadId, reactions,
global = updateReactions(global, actions, chatId, id, reactions, chat, message.isOutgoing, message); });
setGlobal(global); setGlobal(global);
break; break;
} }
@ -939,7 +923,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
const thread = selectThread(global, chatId, threadId); const thread = selectThread(global, chatId, threadId);
if (!thread) return undefined; if (!thread) return undefined;
let typingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); let typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId');
const messageId = typingDraftStore?.[id]; const messageId = typingDraftStore?.[id];
const isUpdatingDraft = Boolean(messageId); const isUpdatingDraft = Boolean(messageId);
@ -949,7 +933,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
// Clear typing draft after timeout // Clear typing draft after timeout
setTimeout(() => { setTimeout(() => {
global = getGlobal(); global = getGlobal();
const currentTypingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const currentTypingDraftStore = selectThreadLocalStateParam(
global, chatId, threadId, 'typingDraftIdByRandomId',
);
if (currentTypingDraftStore?.[id]) { if (currentTypingDraftStore?.[id]) {
const currentMessageId = currentTypingDraftStore[id]; const currentMessageId = currentTypingDraftStore[id];
const currentMessage = selectChatMessage(global, chatId, currentMessageId); 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; if (!currentMessage || getServerTime() - currentMessage.editDate! < global.appConfig.typingDraftTtl) return;
const newTypingDraftIds = omit(currentTypingDraftStore, [id]); const newTypingDraftIds = omit(currentTypingDraftStore, [id]);
global = replaceThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId', newTypingDraftIds); global = replaceThreadLocalStateParam(
global, chatId, threadId, 'typingDraftIdByRandomId', newTypingDraftIds,
);
global = deleteChatMessages(global, chatId, [currentMessageId]); global = deleteChatMessages(global, chatId, [currentMessageId]);
setGlobal(global); setGlobal(global);
} }
@ -998,7 +986,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
...typingDraftStore, ...typingDraftStore,
[id]: newMessage.id, [id]: newMessage.id,
}; };
global = replaceThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId', typingDraftStore); global = replaceThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId', typingDraftStore);
rescheduleDraftRemoval(); rescheduleDraftRemoval();
@ -1010,13 +998,30 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
function updateReactions<T extends GlobalState>( function updateReactions<T extends GlobalState>(
global: T, global: T,
actions: RequiredGlobalActions, actions: RequiredGlobalActions,
chatId: string, {
id: number, chatId, id, threadId, reactions,
reactions: ApiReactions, }: {
chat: ApiChat, chatId: string;
isOutgoing?: boolean, id: number;
message?: ApiMessage, threadId?: ThreadId;
reactions: ApiReactions;
},
): T { ): 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; const currentReactions = message?.reactions;
// `updateMessageReactions` happens with an interval, so we try to avoid redundant global state updates // `updateMessageReactions` happens with an interval, so we try to avoid redundant global state updates
@ -1033,7 +1038,7 @@ function updateReactions<T extends GlobalState>(
global = updateChatMessage(global, chatId, id, { reactions }); global = updateChatMessage(global, chatId, id, { reactions });
if (!isOutgoing) { if (!message.isOutgoing) {
return global; return global;
} }
@ -1045,15 +1050,12 @@ function updateReactions<T extends GlobalState>(
actions.startActiveReaction({ containerId: messageKey, reaction, tabId: getCurrentTabId() }); 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); const hasUnreadReactionsInNewReactions = checkIfHasUnreadReactions(global, reactions);
// Only notify about added reactions, not removed ones // Only notify about added reactions, not removed ones
if (hasUnreadReactionsInNewReactions && !hasUnreadReactionsForMessageInChat) { if (hasUnreadReactionsInNewReactions && !hasUnreadReactionsForMessageInChat) {
global = updateUnreadReactions(global, chatId, { global = addUnreadReactions({ global, chatId, ids: [id] });
unreadReactionsCount: (chat?.unreadReactionsCount || 0) + 1,
unreadReactions: [...(chat?.unreadReactions || []), id].sort((a, b) => b - a),
});
const newMessage = selectChatMessage(global, chatId, id); const newMessage = selectChatMessage(global, chatId, id);
@ -1069,10 +1071,7 @@ function updateReactions<T extends GlobalState>(
} }
if (!hasUnreadReactionsInNewReactions && hasUnreadReactionsForMessageInChat) { if (!hasUnreadReactionsInNewReactions && hasUnreadReactionsForMessageInChat) {
global = updateUnreadReactions(global, chatId, { global = removeUnreadReactions({ global, chatId, ids: [id] });
unreadReactionsCount: (chat?.unreadReactionsCount || 1) - 1,
unreadReactions: chat?.unreadReactions?.filter((i) => i !== id),
});
} }
return global; return global;
@ -1117,48 +1116,17 @@ export function updateWithLocalMedia(
: updateChatMessage(global, chatId, id, newMessage); : updateChatMessage(global, chatId, id, newMessage);
} }
function updateThreadUnread<T extends GlobalState>(
global: T, actions: RequiredGlobalActions, message: ApiMessage, isDeleting?: boolean,
) {
const { chatId } = message;
const replyInfo = getMessageReplyInfo(message);
const { threadInfo } = selectThreadByMessage(global, message) || {};
if (!threadInfo && replyInfo?.replyToMsgId) {
const originMessage = selectChatMessage(global, chatId, replyInfo.replyToMsgId);
if (originMessage) {
global = updateThreadUnreadFromForwardedMessage(global, originMessage, chatId, message.id, isDeleting);
} else {
actions.loadMessage({
chatId,
messageId: replyInfo.replyToMsgId,
threadUpdate: {
isDeleting,
lastMessageId: message.id,
},
});
}
}
return global;
}
function updateListedAndViewportIds<T extends GlobalState>( function updateListedAndViewportIds<T extends GlobalState>(
global: T, actions: RequiredGlobalActions, message: ApiMessage, global: T, message: ApiMessage,
) { ) {
const { id, chatId } = message; const { id, chatId } = message;
const savedDialogId = selectSavedDialogIdFromMessage(global, message); const savedDialogId = selectSavedDialogIdFromMessage(global, message);
const threadId = savedDialogId || selectThreadIdFromMessage(global, message);
const threadInfo = selectThreadInfo(global, chatId, threadId);
const { threadInfo } = selectThreadByMessage(global, message) || {}; const mainThreadReadState = selectThreadReadState(global, chatId, MAIN_THREAD_ID);
const isUnreadChatNotLoaded = mainThreadReadState?.unreadCount && !selectListedIds(global, chatId, MAIN_THREAD_ID);
const chat = selectChat(global, chatId);
const isUnreadChatNotLoaded = chat?.unreadCount && !selectListedIds(global, chatId, MAIN_THREAD_ID);
global = updateThreadUnread(global, actions, message);
const { threadId } = threadInfo ?? { threadId: savedDialogId };
if (threadId) { if (threadId) {
global = updateListedIds(global, chatId, threadId, [id]); global = updateListedIds(global, chatId, threadId, [id]);
@ -1177,15 +1145,11 @@ function updateListedAndViewportIds<T extends GlobalState>(
}); });
if (threadInfo) { if (threadInfo) {
global = replaceThreadParam(global, chatId, threadId, 'threadInfo', { global = updateThreadInfoLastMessageId(global, chatId, threadId, message.id);
...threadInfo,
lastMessageId: message.id,
});
if (!isMessageLocal(message) && !isActionMessage(message)) { if (!isMessageLocal(message) && !isActionMessage(message)) {
global = updateThreadInfo(global, chatId, threadId, { const newCount = (threadInfo.messagesCount || 0) + 1;
messagesCount: (threadInfo.messagesCount || 0) + 1, global = updateThreadInfoMessagesCount(global, chatId, threadId, newCount);
});
} }
} }
} }
@ -1218,21 +1182,10 @@ function updateChatLastMessage<T extends GlobalState>(
message: ApiMessage, message: ApiMessage,
force = false, force = false,
) { ) {
const { chats } = global;
const chat = chats.byId[chatId];
const currentLastMessageId = selectChatLastMessageId(global, 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); const threadId = selectThreadIdFromMessage(global, message);
global = updateThreadInfo(global, chatId, threadId, { global = updateThreadInfoLastMessageId(global, chatId, threadId, message.id);
lastMessageId: message.id,
});
const savedDialogId = selectSavedDialogIdFromMessage(global, message); const savedDialogId = selectSavedDialogIdFromMessage(global, message);
if (savedDialogId) { if (savedDialogId) {
@ -1345,8 +1298,6 @@ export function deleteMessages<T extends GlobalState>(
global = deletePeerPhoto(global, chatId, message.content.action.photo.id, true); global = deletePeerPhoto(global, chatId, message.content.action.photo.id, true);
} }
global = updateThreadUnread(global, actions, message, true);
const threadId = selectThreadIdFromMessage(global, message); const threadId = selectThreadIdFromMessage(global, message);
if (threadId) { if (threadId) {
threadIdsToUpdate.add(threadId); threadIdsToUpdate.add(threadId);
@ -1358,14 +1309,17 @@ export function deleteMessages<T extends GlobalState>(
const idsSet = new Set(ids); const idsSet = new Set(ids);
threadIdsToUpdate.forEach((threadId) => { 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); const threadInfo = selectThreadInfo(global, chatId, threadId);
if (!threadInfo?.lastMessageId || !idsSet.has(threadInfo.lastMessageId)) return; if (!threadInfo?.lastMessageId || !idsSet.has(threadInfo.lastMessageId)) return;
const newLastMessage = findLastMessage(global, chatId, threadId); const newLastMessage = findLastMessage(global, chatId, threadId);
if (!newLastMessage) { if (!newLastMessage) {
if (chat.isForum && threadId !== MAIN_THREAD_ID) {
actions.loadTopicById({ chatId, topicId: Number(threadId) });
}
return; return;
} }
@ -1373,15 +1327,7 @@ export function deleteMessages<T extends GlobalState>(
global = updateChatLastMessage(global, chatId, newLastMessage, true); global = updateChatLastMessage(global, chatId, newLastMessage, true);
} }
global = updateThreadInfo(global, chatId, threadId, { global = updateThreadInfoLastMessageId(global, chatId, threadId, newLastMessage.id);
lastMessageId: newLastMessage.id,
});
if (chat.isForum) {
global = updateTopic(global, chatId, Number(threadId), {
lastMessageId: newLastMessage.id,
});
}
}); });
setGlobal(global); setGlobal(global);
@ -1466,10 +1412,6 @@ function deleteScheduledMessages<T extends GlobalState>(
setTimeout(() => { setTimeout(() => {
global = getGlobal(); global = getGlobal();
global = deleteChatScheduledMessages(global, chatId, ids); 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); setGlobal(global);
}, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY); }, isAnimatingAsSnap ? SNAP_ANIMATION_DELAY : ANIMATION_DELAY);
} }

View File

@ -21,9 +21,9 @@ import {
updatePeersWithStories, updatePeersWithStories,
updatePoll, updatePoll,
updateStealthMode, updateStealthMode,
updateThreadInfos,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { updateThreadInfo } from '../../reducers/threads';
import { import {
selectPeer, selectPeer,
selectPeerStories, selectPeerStories,
@ -39,7 +39,11 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} = update; } = update;
if (users) global = addUsers(global, users); if (users) global = addUsers(global, users);
if (chats) global = addChats(global, chats); if (chats) global = addChats(global, chats);
if (threadInfos) global = updateThreadInfos(global, threadInfos); if (threadInfos) {
threadInfos.forEach((threadInfo) => {
global = updateThreadInfo(global, threadInfo);
});
}
if (polls) { if (polls) {
polls.forEach((poll) => { polls.forEach((poll) => {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.id, poll);
@ -305,7 +309,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
if (receiver) { if (receiver) {
actions.focusMessage({ actions.focusMessage({
chatId: receiver.id, chatId: receiver.id,
messageId: update.message.id!, messageId: update.message.id,
tabId, tabId,
}); });

View File

@ -7,9 +7,10 @@ import { createMessageHashUrl } from '../../../util/routing';
import { addActionHandler, execAfterActions, getGlobal, setGlobal } from '../../index'; import { addActionHandler, execAfterActions, getGlobal, setGlobal } from '../../index';
import { import {
closeMiddleSearch, closeMiddleSearch,
exitMessageSelectMode, replaceTabThreadParam, updateCurrentMessageList, updateRequestedChatTranslation, exitMessageSelectMode, updateCurrentMessageList, updateRequestedChatTranslation,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { replaceTabThreadParam } from '../../reducers/threads';
import { import {
selectChat, selectCurrentMessageList, selectTabState, selectChat, selectCurrentMessageList, selectTabState,
} from '../../selectors'; } from '../../selectors';

View File

@ -37,12 +37,11 @@ import {
cancelMessageMediaDownload, cancelMessageMediaDownload,
enterMessageSelectMode, enterMessageSelectMode,
exitMessageSelectMode, exitMessageSelectMode,
replaceTabThreadParam,
replaceThreadParam,
toggleMessageSelection, toggleMessageSelection,
updateFocusedMessage, updateFocusedMessage,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { replaceTabThreadParam, replaceThreadLocalStateParam } from '../../reducers/threads';
import { import {
selectAllowedMessageActionsSlow, selectAllowedMessageActionsSlow,
selectCanForwardMessage, selectCanForwardMessage,
@ -53,20 +52,18 @@ import {
selectChatScheduledMessages, selectChatScheduledMessages,
selectCurrentChat, selectCurrentChat,
selectCurrentMessageList, selectCurrentMessageList,
selectDraft,
selectForwardedMessageIdsByGroupId, selectForwardedMessageIdsByGroupId,
selectIsRightColumnShown, selectIsRightColumnShown,
selectIsViewportNewest, selectIsViewportNewest,
selectMessageIdsByGroupId, selectMessageIdsByGroupId,
selectReplyStack,
selectRequestedChatTranslationLanguage, selectRequestedChatTranslationLanguage,
selectRequestedMessageTranslationLanguage, selectRequestedMessageTranslationLanguage,
selectSender, selectSender,
selectTabState, selectTabState,
selectThreadInfo,
selectViewportIds, selectViewportIds,
} from '../../selectors'; } from '../../selectors';
import { selectMessageDownloadableMedia } from '../../selectors/media'; import { selectMessageDownloadableMedia } from '../../selectors/media';
import { selectDraft, selectReplyStack, selectThreadInfo } from '../../selectors/threads';
import { getPeerStarsForMessage } from '../api/messages'; import { getPeerStarsForMessage } from '../api/messages';
import { getIsMobile } from '../../../hooks/useAppLayout'; import { getIsMobile } from '../../../hooks/useAppLayout';
@ -84,7 +81,7 @@ addActionHandler('setScrollOffset', (global, actions, payload): ActionReturnType
chatId, threadId, scrollOffset, tabId = getCurrentTabId(), chatId, threadId, scrollOffset, tabId = getCurrentTabId(),
} = payload; } = payload;
global = replaceThreadParam(global, chatId, threadId, 'lastScrollOffset', scrollOffset); global = replaceThreadLocalStateParam(global, chatId, threadId, 'lastScrollOffset', scrollOffset);
return replaceTabThreadParam(global, chatId, threadId, 'scrollOffset', scrollOffset, tabId); return replaceTabThreadParam(global, chatId, threadId, 'scrollOffset', scrollOffset, tabId);
}); });
@ -99,7 +96,7 @@ addActionHandler('setEditingId', (global, actions, payload): ActionReturnType =>
const { chatId, threadId, type } = currentMessageList; const { chatId, threadId, type } = currentMessageList;
const paramName = type === 'scheduled' ? 'editingScheduledId' : 'editingId'; 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 => { addActionHandler('setEditingDraft', (global, actions, payload): ActionReturnType => {
@ -109,7 +106,7 @@ addActionHandler('setEditingDraft', (global, actions, payload): ActionReturnType
const paramName = type === 'scheduled' ? 'editingScheduledDraft' : 'editingDraft'; 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 => { addActionHandler('editLastMessage', (global, actions, payload): ActionReturnType => {
@ -133,7 +130,7 @@ addActionHandler('editLastMessage', (global, actions, payload): ActionReturnType
return undefined; return undefined;
} }
return replaceThreadParam(global, chatId, threadId, 'editingId', lastOwnEditableMessageId); return replaceThreadLocalStateParam(global, chatId, threadId, 'editingId', lastOwnEditableMessageId);
}); });
addActionHandler('replyToNextMessage', (global, actions, payload): ActionReturnType => { addActionHandler('replyToNextMessage', (global, actions, payload): ActionReturnType => {

View File

@ -33,6 +33,7 @@ import { GLOBAL_STATE_CACHE_KEY } from '../util/multiaccount';
import { encryptSession } from '../util/passcode'; import { encryptSession } from '../util/passcode';
import { onBeforeUnload, throttle } from '../util/schedulers'; import { onBeforeUnload, throttle } from '../util/schedulers';
import { hasStoredSession } from '../util/sessions'; import { hasStoredSession } from '../util/sessions';
import { selectThreadInfo } from './selectors/threads';
import { addActionHandler, getGlobal } from './index'; import { addActionHandler, getGlobal } from './index';
import { INITIAL_GLOBAL_STATE, INITIAL_PERFORMANCE_STATE_MED } from './initialState'; import { INITIAL_GLOBAL_STATE, INITIAL_PERFORMANCE_STATE_MED } from './initialState';
import { clearGlobalForLockScreen, clearSharedStateForLockScreen } from './reducers'; import { clearGlobalForLockScreen, clearSharedStateForLockScreen } from './reducers';
@ -42,6 +43,7 @@ import {
selectCurrentMessageList, selectCurrentMessageList,
selectFullWebPageFromMessage, selectFullWebPageFromMessage,
selectTopics, selectTopics,
selectTopicsInfo,
selectViewportIds, selectViewportIds,
selectVisibleUsers, selectVisibleUsers,
} from './selectors'; } from './selectors';
@ -364,6 +366,12 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
untypedCached.sharedState.settings.shouldWarnAboutSvg = undefined; 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) { if (!cached.auth) {
cached.auth = initialState.auth; cached.auth = initialState.auth;
cached.auth.rememberMe = untypedCached.rememberMe; cached.auth.rememberMe = untypedCached.rememberMe;
@ -674,18 +682,20 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
const chatLastMessageId = selectChatLastMessageId(global, chatId); const chatLastMessageId = selectChatLastMessageId(global, chatId);
const topicsInfo = selectTopicsInfo(global, chatId);
const openedThreadIds = Array.from(openedChatThreadIds[chatId] || []); const openedThreadIds = Array.from(openedChatThreadIds[chatId] || []);
const commentThreadIds = Object.values(global.messages.byChatId[chatId].threadsById || {}) const commentThreadIds = Object.values(global.messages.byChatId[chatId].threadsById || {})
.map(({ threadInfo }) => (threadInfo?.isCommentsInfo ? threadInfo?.originMessageId : undefined)) .map(({ threadInfo }) => (threadInfo?.isCommentsInfo ? threadInfo?.originMessageId : undefined))
.filter(Boolean); .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 threadsToSave = pickTruthy(current.threadsById, [MAIN_THREAD_ID, ...threadIds]);
const viewportIdsToSave = unique(Object.values(threadsToSave).flatMap((thread) => thread.lastViewportIds || [])); const viewportIdsToSave = unique(Object.values(threadsToSave)
const topics = selectTopics(global, chatId); .flatMap((thread) => thread.localState?.lastViewportIds || []));
const topicLastMessageIds = topics && forumPanelChatIds.includes(chatId) 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 const savedLastMessageIds = chatId === currentUserId && global.chats.lastMessageIds.saved
? Object.values(global.chats.lastMessageIds.saved) : []; ? Object.values(global.chats.lastMessageIds.saved) : [];
const lastMessageIdsToSave = [chatLastMessageId].concat(topicLastMessageIds).concat(savedLastMessageIds) const lastMessageIdsToSave = [chatLastMessageId].concat(topicLastMessageIds).concat(savedLastMessageIds)
@ -695,9 +705,11 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
const thread = threadsToSave[Number(key)]; const thread = threadsToSave[Number(key)];
acc[Number(key)] = { acc[Number(key)] = {
...thread, ...thread,
listedIds: thread.lastViewportIds, localState: {
pinnedIds: undefined, ...thread.localState,
typingStatus: undefined, listedIds: thread.localState?.lastViewportIds,
typingStatus: undefined,
},
}; };
return acc; return acc;
}, {} as GlobalState['messages']['byChatId'][string]['threadsById']); }, {} as GlobalState['messages']['byChatId'][string]['threadsById']);

View File

@ -9,6 +9,7 @@ import type {
ApiPeer, ApiPeer,
ApiPeerColorCollectible, ApiPeerColorCollectible,
ApiPreparedInlineMessage, ApiPreparedInlineMessage,
ApiThreadInfo,
ApiTopic, ApiTopic,
} from '../../api/types'; } from '../../api/types';
import type { OldLangFn } from '../../hooks/useOldLang'; import type { OldLangFn } from '../../hooks/useOldLang';
@ -346,20 +347,28 @@ export function isChatPublic(chat: ApiChat) {
} }
export function getOrderedTopics( export function getOrderedTopics(
topics: ApiTopic[], pinnedOrder?: number[], shouldSortByLastMessage = false, topics: ApiTopic[],
topicThreadInfos?: Record<ThreadId, ApiThreadInfo>,
pinnedOrder?: number[],
shouldSortByLastMessage = false,
): ApiTopic[] { ): ApiTopic[] {
const lastMessageIdComparator = (a: ApiTopic, b: ApiTopic) => (
(topicThreadInfos?.[b.id]?.lastMessageId || 0) - (topicThreadInfos?.[a.id]?.lastMessageId || 0)
);
if (shouldSortByLastMessage) { if (shouldSortByLastMessage) {
return topics.sort((a, b) => b.lastMessageId - a.lastMessageId); return topics.sort(lastMessageIdComparator);
} else { } else {
const pinned = topics.filter((topic) => topic.isPinned); const pinned = topics.filter((topic) => topic.isPinned);
const ordered = topics const ordered = topics
.filter((topic) => !topic.isPinned && !topic.isHidden) .filter((topic) => !topic.isPinned && !topic.isHidden)
.sort((a, b) => b.lastMessageId - a.lastMessageId); .sort(lastMessageIdComparator);
const hidden = topics.filter((topic) => !topic.isPinned && topic.isHidden) const hidden = topics.filter((topic) => !topic.isPinned && topic.isHidden)
.sort((a, b) => b.lastMessageId - a.lastMessageId); .sort(lastMessageIdComparator);
const pinnedOrdered = pinnedOrder 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]; return [...pinnedOrdered, ...ordered, ...hidden];
} }

View File

@ -35,7 +35,13 @@ import { areSortedArraysIntersecting, unique } from '../../util/iteratees';
import { isLocalMessageId } from '../../util/keys/messageKey'; import { isLocalMessageId } from '../../util/keys/messageKey';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { getGlobal } from '../index'; 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'; import { getMainUsername } from './users';
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
@ -546,3 +552,24 @@ export function createApiMessageFromTypingDraft({
editDate: getServerTime(), editDate: getServerTime(),
}; };
} }
export function groupMessageIdsByThreadId(
global: GlobalState, chatId: string, messageIds: number[], isScheduled: boolean, notShared?: boolean,
): Record<ThreadId, number[]> {
const grouped = messageIds.reduce<Record<ThreadId, number[]>>((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;
}

View File

@ -13,7 +13,7 @@ import type { LangFn } from '../../util/localization';
import type { GlobalState } from '../types'; import type { GlobalState } from '../types';
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../config'; 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 { convertTonFromNanos } from '../../util/formatCurrency';
import { selectChat, selectPeer, selectUser } from '../selectors'; import { selectChat, selectPeer, selectUser } from '../selectors';
@ -494,5 +494,5 @@ export function getPrizeStarsTransactionFromGiveaway(message: ApiMessage): ApiSt
} }
export function areInputSavedGiftsEqual(one: ApiInputSavedStarGift, two: ApiInputSavedStarGift) { export function areInputSavedGiftsEqual(one: ApiInputSavedStarGift, two: ApiInputSavedStarGift) {
return arePropsShallowEqual(one, two); return areRecordsShallowEqual(one, two);
} }

View File

@ -13,14 +13,15 @@ import { parseLocationHash } from '../util/routing';
import { updatePeerColors } from '../util/theme'; import { updatePeerColors } from '../util/theme';
import { initializeChatMediaSearchResults } from './reducers/middleSearch'; import { initializeChatMediaSearchResults } from './reducers/middleSearch';
import { updateTabState } from './reducers/tabs'; import { updateTabState } from './reducers/tabs';
import { replaceTabThreadParam, replaceThreadLocalStateParam } from './reducers/threads';
import { selectThreadLocalStateParam } from './selectors/threads';
import { initSharedState } from './shared/sharedStateConnector'; import { initSharedState } from './shared/sharedStateConnector';
import { initCache } from './cache'; import { initCache } from './cache';
import { import {
addActionHandler, getGlobal, setGlobal, addActionHandler, getGlobal, setGlobal,
} from './index'; } from './index';
import { INITIAL_TAB_STATE } from './initialState'; import { INITIAL_TAB_STATE } from './initialState';
import { replaceTabThreadParam, replaceThreadParam } from './reducers'; import { selectTabState } from './selectors';
import { selectTabState, selectThreadParam } from './selectors';
initCache(); initCache();
@ -63,10 +64,10 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => {
const threadsById = global.messages.byChatId[chatId].threadsById; const threadsById = global.messages.byChatId[chatId].threadsById;
Object.keys(threadsById).forEach((thread) => { Object.keys(threadsById).forEach((thread) => {
const threadId = Number(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 // Check if migration from previous version is faulty
if (!lastViewportIds?.every((id) => isLocalMessageId(id) || global.messages.byChatId[chatId]?.byId[id])) { 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; return;
} }
global = initializeChatMediaSearchResults(global, chatId, threadId, tabId); global = initializeChatMediaSearchResults(global, chatId, threadId, tabId);
@ -85,10 +86,18 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => {
Object.keys(global.messages.byChatId).forEach((chatId) => { Object.keys(global.messages.byChatId).forEach((chatId) => {
const threadsById = global.messages.byChatId[chatId].threadsById; const threadsById = global.messages.byChatId[chatId].threadsById;
const fixedThreadsById = Object.keys(threadsById).reduce((acc, key) => { const fixedThreadsById = Object.keys(threadsById).reduce((acc, key) => {
const t = threadsById[Number(key)]; const t = threadsById[key];
acc[Number(key)] = { if (!t.localState?.lastViewportIds) {
acc[key] = t;
return acc;
}
acc[key] = {
...t, ...t,
listedIds: t.lastViewportIds, localState: {
...t.localState,
listedIds: t.localState.lastViewportIds,
},
}; };
return acc; return acc;
}, {} as GlobalState['messages']['byChatId'][string]['threadsById']); }, {} as GlobalState['messages']['byChatId'][string]['threadsById']);

View File

@ -96,7 +96,7 @@ export const INITIAL_SHARED_STATE: SharedState = {
}; };
export const INITIAL_GLOBAL_STATE: GlobalState = { export const INITIAL_GLOBAL_STATE: GlobalState = {
cacheVersion: 2, cacheVersion: 3,
isInited: true, isInited: true,
attachMenu: { bots: {} }, attachMenu: { bots: {} },
passcode: {}, passcode: {},

View File

@ -1,15 +1,19 @@
import type {
ApiChat, ApiChatFullInfo, ApiChatMember,
} from '../../api/types';
import type { ChatListType } from '../../types'; import type { ChatListType } from '../../types';
import type { GlobalState } 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 { ARCHIVED_FOLDER_ID } from '../../config';
import { areDeepEqual } from '../../util/areDeepEqual'; import { areDeepEqual } from '../../util/areDeepEqual';
import { import {
areSortedArraysEqual, buildCollectionByKey, omit, omitUndefined, pick, unique, areSortedArraysEqual, buildCollectionByKey, omit, omitUndefined, pick, unique,
} from '../../util/iteratees'; } from '../../util/iteratees';
import { groupMessageIdsByThreadId } from '../helpers';
import { selectChatFullInfo } from '../selectors'; import { selectChatFullInfo } from '../selectors';
import { selectThreadReadState } from '../selectors/threads';
import { replaceThreadReadStateParam, updateThreadInfoLastMessageId } from './threads';
const DEFAULT_CHAT_LISTS: ChatListType[] = ['active', 'archived']; const DEFAULT_CHAT_LISTS: ChatListType[] = ['active', 'archived'];
@ -53,6 +57,11 @@ export function updateChatLastMessageId<T extends GlobalState>(
global: T, chatId: string, lastMessageId: number, listType?: ChatListType, global: T, chatId: string, lastMessageId: number, listType?: ChatListType,
): T { ): T {
const key = listType === 'saved' ? 'saved' : 'all'; const key = listType === 'saved' ? 'saved' : 'all';
if (key === 'all') {
global = updateThreadInfoLastMessageId(global, chatId, MAIN_THREAD_ID, lastMessageId);
}
return { return {
...global, ...global,
chats: { chats: {
@ -115,49 +124,67 @@ export function replaceChats<T extends GlobalState>(global: T, newById: Record<s
}; };
} }
export function addUnreadMentions<T extends GlobalState>( export function addUnreadMentions<T extends GlobalState>({
global: T, chatId: string, chat: ApiChat, ids: number[], shouldUpdateCount: boolean = false, global, chatId, ids, totalCount,
): T { }: {
const prevChatUnreadMentions = (chat.unreadMentions || []); global: T;
const updatedUnreadMentions = unique([...prevChatUnreadMentions, ...ids]).sort((a, b) => b - a); chatId: string;
global = updateChat(global, chatId, { ids: number[];
unreadMentions: updatedUnreadMentions, totalCount?: number;
}); }): T {
const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, ids, false);
if (shouldUpdateCount) { for (const threadId in messageIdsByThreadId) {
const updatedUnreadMentionsCount = (chat.unreadMentionsCount || 0) 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); + Math.max(0, updatedUnreadMentions.length - prevChatUnreadMentions.length);
global = updateChat(global, chatId, { global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentionsCount', updatedUnreadMentionsCount);
unreadMentionsCount: updatedUnreadMentionsCount,
});
} }
return global; return global;
} }
export function removeUnreadMentions<T extends GlobalState>( export function removeUnreadMentions<T extends GlobalState>({
global: T, chatId: string, chat: ApiChat, ids: number[], shouldUpdateCount: boolean = false, global, chatId, ids,
): T { }: {
const prevChatUnreadMentions = (chat.unreadMentions || []); global: T;
const updatedUnreadMentions = prevChatUnreadMentions?.filter((id) => !ids.includes(id)); chatId: string;
ids: number[];
}): T {
const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, ids, false);
global = updateChat(global, chatId, { for (const threadId in messageIdsByThreadId) {
unreadMentions: updatedUnreadMentions, 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 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, { global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadMentionsCount', updatedUnreadMentionsCount);
unreadMentionsCount: updatedUnreadMentionsCount, }
});
} }
return global; return global;
} }
export function updateChat<T extends GlobalState>( export function updateChat<T extends GlobalState>(
global: T, chatId: string, chatUpdate: Partial<ApiChat>, noOmitUnreadReactionCount = false, withDeepCheck = false, global: T, chatId: string, chatUpdate: Partial<ApiChat>, withDeepCheck = false,
): T { ): T {
const { byId } = global.chats; const { byId } = global.chats;
@ -169,7 +196,7 @@ export function updateChat<T extends GlobalState>(
} }
} }
const updatedChat = getUpdatedChat(global, chatId, chatUpdate, noOmitUnreadReactionCount); const updatedChat = getUpdatedChat(global, chatId, chatUpdate);
if (!updatedChat) { if (!updatedChat) {
return global; return global;
} }
@ -284,7 +311,6 @@ export function addChats<T extends GlobalState>(global: T, newById: Record<strin
// @optimization Don't spread/unspread global for each element, do it in a batch // @optimization Don't spread/unspread global for each element, do it in a batch
function getUpdatedChat<T extends GlobalState>( function getUpdatedChat<T extends GlobalState>(
global: T, chatId: string, chatUpdate: Partial<ApiChat>, global: T, chatId: string, chatUpdate: Partial<ApiChat>,
noOmitUnreadReactionCount = false,
) { ) {
const { byId } = global.chats; const { byId } = global.chats;
const chat = byId[chatId]; const chat = byId[chatId];
@ -294,10 +320,6 @@ function getUpdatedChat<T extends GlobalState>(
return undefined; // Do not apply updates from min constructor return undefined; // Do not apply updates from min constructor
} }
if (!noOmitUnreadReactionCount) {
omitProps.push('unreadReactionsCount');
}
if (areDeepEqual(chat?.usernames, chatUpdate.usernames)) { if (areDeepEqual(chat?.usernames, chatUpdate.usernames)) {
omitProps.push('usernames'); omitProps.push('usernames');
} }

View File

@ -1,14 +1,12 @@
import type { import type {
ApiFormattedText, ApiFormattedText,
ApiMessage, ApiPoll, ApiPollResult, ApiQuickReply, ApiSponsoredMessage, ApiThreadInfo, ApiMessage, ApiPoll, ApiPollResult, ApiQuickReply, ApiSponsoredMessage,
ApiWebPage, ApiWebPage,
ApiWebPageFull, ApiWebPageFull,
} from '../../api/types'; } from '../../api/types';
import type { import type {
MessageList, MessageList,
MessageListType, MessageListType,
TabThread,
Thread,
ThreadId, ThreadId,
} from '../../types'; } from '../../types';
import type { import type {
@ -30,6 +28,7 @@ import { unload } from '../../util/mediaLoader';
import { import {
getAllMessageMediaHashes, getAllMessageMediaHashes,
getMessageStatefulContent, getMessageStatefulContent,
groupMessageIdsByThreadId,
hasMessageTtl, isMediaLoadableInViewer, mergeIdRanges, orderHistoryIds, orderPinnedIds, hasMessageTtl, isMediaLoadableInViewer, mergeIdRanges, orderHistoryIds, orderPinnedIds,
} from '../helpers'; } from '../helpers';
import { getEmojiOnlyCountForMessage } from '../helpers/getEmojiOnlyCountForMessage'; import { getEmojiOnlyCountForMessage } from '../helpers/getEmojiOnlyCountForMessage';
@ -45,17 +44,22 @@ import {
selectPinnedIds, selectPinnedIds,
selectPoll, selectPoll,
selectQuickReplyMessage, selectQuickReplyMessage,
selectScheduledIds,
selectScheduledMessage, selectScheduledMessage,
selectTabState, selectTabState,
selectThreadIdFromMessage,
selectThreadInfo,
selectThreadParam,
selectViewportIds, selectViewportIds,
selectWebPage, selectWebPage,
} from '../selectors'; } from '../selectors';
import { selectThreadIdFromMessage, selectThreadInfo, selectThreadLocalStateParam } from '../selectors/threads';
import { removeUnreadMentions } from './chats';
import { removeIdFromSearchResults } from './middleSearch'; import { removeIdFromSearchResults } from './middleSearch';
import { removeUnreadReactions } from './reactions';
import { updateTabState } from './tabs'; import { updateTabState } from './tabs';
import {
deleteThread,
replaceTabThreadParam,
replaceThreadLocalStateParam,
updateThreadInfoMessagesCount,
} from './threads';
import { clearMessageSummary, clearMessageTranslation } from './translations'; import { clearMessageSummary, clearMessageTranslation } from './translations';
type MessageStoreSections = GlobalState['messages']['byChatId'][string]; type MessageStoreSections = GlobalState['messages']['byChatId'][string];
@ -107,49 +111,6 @@ export function replaceChatMessages<T extends GlobalState>(
}); });
} }
export function updateTabThread<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial<TabThread>,
...[tabId = getCurrentTabId()]: TabArgs<T>
): 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<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial<Thread> | 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<T extends GlobalState>( export function updateMessageStore<T extends GlobalState>(
global: T, chatId: string, update: Partial<MessageStoreSections>, global: T, chatId: string, update: Partial<MessageStoreSections>,
): T { ): T {
@ -171,24 +132,6 @@ export function updateMessageStore<T extends GlobalState>(
}; };
} }
export function replaceTabThreadParam<T extends GlobalState, K extends keyof TabThread>(
global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: TabThread[K] | undefined,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
if (paramName === 'viewportIds') {
global = replaceThreadParam(
global, chatId, threadId, 'lastViewportIds', newValue as number[] | undefined,
);
}
return updateTabThread(global, chatId, threadId, { [paramName]: newValue }, tabId);
}
export function replaceThreadParam<T extends GlobalState, K extends keyof Thread>(
global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: Thread[K] | undefined,
) {
return updateThread(global, chatId, threadId, { [paramName]: newValue });
}
export function addMessages<T extends GlobalState>( export function addMessages<T extends GlobalState>(
global: T, messages: ApiMessage[], global: T, messages: ApiMessage[],
): T { ): T {
@ -462,14 +405,12 @@ export function deleteChatMessages<T extends GlobalState>(
); );
}); });
global = replaceThreadParam(global, chatId, threadId, 'listedIds', listedIds); global = replaceThreadLocalStateParam(global, chatId, threadId, 'listedIds', listedIds);
global = replaceThreadParam(global, chatId, threadId, 'outlyingLists', outlyingLists); global = replaceThreadLocalStateParam(global, chatId, threadId, 'outlyingLists', outlyingLists);
global = replaceThreadParam(global, chatId, threadId, 'pinnedIds', pinnedIds); global = replaceThreadLocalStateParam(global, chatId, threadId, 'pinnedIds', pinnedIds);
if (threadInfo && newMessageCount !== undefined) { if (threadInfo && newMessageCount !== undefined) {
global = updateThreadInfo(global, chatId, threadId, { global = updateThreadInfoMessagesCount(global, chatId, threadId, newMessageCount);
messagesCount: newMessageCount,
});
} }
}); });
@ -488,12 +429,15 @@ export function deleteChatMessages<T extends GlobalState>(
global = updateCurrentMessageList(global, chatId, undefined, undefined, undefined, undefined, tabId); global = updateCurrentMessageList(global, chatId, undefined, undefined, undefined, undefined, tabId);
} }
if (originalPost) { 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); const newById = omit(byId, messageIds);
global = replaceChatMessages(global, chatId, newById); global = replaceChatMessages(global, chatId, newById);
@ -511,22 +455,15 @@ export function deleteChatScheduledMessages<T extends GlobalState>(
} }
const newById = omit(byId, messageIds); const newById = omit(byId, messageIds);
let scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID); const groupedByThreadId = groupMessageIdsByThreadId(global, chatId, messageIds, true);
if (scheduledIds) { Object.entries(groupedByThreadId).forEach(([tId, newThreadScheduledIds]) => {
messageIds.forEach((messageId) => { const threadId = tId as ThreadId;
if (scheduledIds!.includes(messageId)) { const scheduledIds = selectThreadLocalStateParam(global, chatId, threadId, 'scheduledIds');
scheduledIds = scheduledIds!.filter((id) => id !== messageId); if (!scheduledIds?.length) return;
}
});
global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', scheduledIds);
Object.entries(global.messages.byChatId[chatId].threadsById).forEach(([threadId, thread]) => { const cleanedScheduledIds = scheduledIds.filter((id) => !newThreadScheduledIds.includes(id));
if (thread.scheduledIds) { global = replaceThreadLocalStateParam(global, chatId, threadId, 'scheduledIds', cleanedScheduledIds);
const newScheduledIds = thread.scheduledIds.filter((id) => !messageIds.includes(id)); });
global = replaceThreadParam(global, chatId, Number(threadId), 'scheduledIds', newScheduledIds);
}
});
}
global = { global = {
...global, ...global,
@ -558,7 +495,7 @@ export function updateListedIds<T extends GlobalState>(
return global; return global;
} }
return replaceThreadParam(global, chatId, threadId, 'listedIds', orderHistoryIds([ return replaceThreadLocalStateParam(global, chatId, threadId, 'listedIds', orderHistoryIds([
...(listedIds || []), ...(listedIds || []),
...newIds, ...newIds,
])); ]));
@ -577,7 +514,7 @@ export function removeOutlyingList<T extends GlobalState>(
const newOutlyingLists = outlyingLists.filter((l) => l !== list); const newOutlyingLists = outlyingLists.filter((l) => l !== list);
return replaceThreadParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists); return replaceThreadLocalStateParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists);
} }
export function updateOutlyingLists<T extends GlobalState>( export function updateOutlyingLists<T extends GlobalState>(
@ -592,7 +529,7 @@ export function updateOutlyingLists<T extends GlobalState>(
const newOutlyingLists = mergeIdRanges(outlyingLists || [], idsUpdate); const newOutlyingLists = mergeIdRanges(outlyingLists || [], idsUpdate);
return replaceThreadParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists); return replaceThreadLocalStateParam(global, chatId, threadId, 'outlyingLists', newOutlyingLists);
} }
export function addViewportId<T extends GlobalState>( export function addViewportId<T extends GlobalState>(
@ -648,7 +585,7 @@ export function safeReplacePinnedIds<T extends GlobalState>(
const currentIds = selectPinnedIds(global, chatId, threadId) || []; const currentIds = selectPinnedIds(global, chatId, threadId) || [];
const newIds = orderPinnedIds(newPinnedIds); const newIds = orderPinnedIds(newPinnedIds);
return replaceThreadParam( return replaceThreadLocalStateParam(
global, global,
chatId, chatId,
threadId, threadId,
@ -657,42 +594,6 @@ export function safeReplacePinnedIds<T extends GlobalState>(
); );
} }
export function updateThreadInfo<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, update: Partial<ApiThreadInfo> | 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<T extends GlobalState>(
global: T, updates: Partial<ApiThreadInfo>[],
): 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<T extends GlobalState>( export function updateScheduledMessages<T extends GlobalState>(
global: T, chatId: string, newById: Record<number, ApiMessage>, global: T, chatId: string, newById: Record<number, ApiMessage>,
): T { ): T {
@ -852,27 +753,6 @@ export function exitMessageSelectMode<T extends GlobalState>(
}, tabId); }, tabId);
} }
export function updateThreadUnreadFromForwardedMessage<T extends GlobalState>(
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<T extends GlobalState>( export function addActiveMediaDownload<T extends GlobalState>(
global: T, global: T,
mediaHash: string, mediaHash: string,
@ -1086,7 +966,7 @@ export function updateTypingDraft<T extends GlobalState>(
randomId: string, randomId: string,
text: ApiFormattedText, text: ApiFormattedText,
) { ) {
const typingDraftStore = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId'); const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId');
const messageId = typingDraftStore?.[randomId]; const messageId = typingDraftStore?.[randomId];
if (!messageId) { if (!messageId) {
return global; return global;

View File

@ -1,16 +1,18 @@
import type { ApiChat, ApiMessage, ApiReactionWithPaid } from '../../api/types';
import type { GlobalState } from '../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 { 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 windowSize from '../../util/windowSize';
import { import {
MIN_LEFT_COLUMN_WIDTH, MIN_LEFT_COLUMN_WIDTH,
SIDE_COLUMN_MAX_WIDTH, SIDE_COLUMN_MAX_WIDTH,
} from '../../components/middle/helpers/calculateMiddleFooterTransforms'; } from '../../components/middle/helpers/calculateMiddleFooterTransforms';
import { updateReactionCount } from '../helpers'; import { groupMessageIdsByThreadId, updateReactionCount } from '../helpers';
import { selectIsChatWithSelf, selectSendAs, selectTabState } from '../selectors'; import { selectIsChatWithSelf, selectSendAs, selectTabState } from '../selectors';
import { updateChat } from './chats'; import { selectThreadReadState } from '../selectors/threads';
import { updateChatMessage } from './messages'; import { updateChatMessage } from './messages';
import { replaceThreadReadStateParam } from './threads';
import { getIsMobile } from '../../hooks/useAppLayout'; import { getIsMobile } from '../../hooks/useAppLayout';
@ -75,8 +77,60 @@ export function addMessageReaction<T extends GlobalState>(
}); });
} }
export function updateUnreadReactions<T extends GlobalState>( export function addUnreadReactions<T extends GlobalState>({
global: T, chatId: string, update: Pick<ApiChat, 'unreadReactionsCount' | 'unreadReactions'>, global, chatId, ids, totalCount,
): T { }: {
return updateChat(global, chatId, update, true); 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<T extends GlobalState>({
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;
} }

View File

@ -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<T extends GlobalState, K extends keyof TabThread>(
global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: TabThread[K] | undefined,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
if (paramName === 'viewportIds') {
global = replaceThreadLocalStateParam(
global, chatId, threadId, 'lastViewportIds', newValue as number[] | undefined,
);
}
return updateTabThread(global, chatId, threadId, { [paramName]: newValue }, tabId);
}
export function replaceThreadLocalStateParam<T extends GlobalState, K extends keyof ThreadLocalState>(
global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: ThreadLocalState[K] | undefined,
) {
return updateThreadLocalState(global, chatId, threadId, { [paramName]: newValue });
}
export function replaceThreadReadStateParam<T extends GlobalState, K extends keyof ThreadReadState>(
global: T, chatId: string, threadId: ThreadId, paramName: K, newValue: ThreadReadState[K] | undefined,
) {
return updateThreadReadState(global, chatId, threadId, { [paramName]: newValue });
}
export function updateTabThread<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial<TabThread>,
...[tabId = getCurrentTabId()]: TabArgs<T>
): 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<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial<ThreadLocalState> | 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<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, threadUpdate: Partial<ThreadReadState>,
): 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<T extends GlobalState>(
global: T, update: Partial<ApiThreadInfo> | 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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(
global: T, threadReadStates: Record<string, ThreadReadState>,
): T {
Object.entries(threadReadStates).forEach(([chatId, readState]) => {
global = updateThreadReadState(global, chatId, MAIN_THREAD_ID, readState);
});
return global;
}
export function updateThreadReadStates<T extends GlobalState>(
global: T, chatId: string, threadReadStates: Record<ThreadId, ThreadReadState>,
): T {
Object.entries(threadReadStates).forEach(([threadId, readState]) => {
global = updateThreadReadState(global, chatId, threadId, readState);
});
return global;
}
export function deleteThread<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId,
): T {
return updateMessageStore(global, chatId, {
threadsById: omit(global.messages.byChatId[chatId]?.threadsById, [threadId]),
});
}

View File

@ -1,15 +1,30 @@
import type { ApiTopic } from '../../api/types'; import type { ApiTopic, ApiTopicWithState } from '../../api/types';
import type { TopicsInfo } from '../../types'; import type { TopicsInfo } from '../../types';
import type { GlobalState } from '../types'; import type { GlobalState } from '../types';
import { buildCollectionByKey, omit, unique } from '../../util/iteratees'; import { omit, pick, unique } from '../../util/iteratees';
import { import {
selectChat, selectTopic, selectTopics, selectChat, selectTopic, selectTopics,
selectTopicsInfo, selectTopicsInfo,
} from '../selectors'; } from '../selectors';
import { updateThread, updateThreadInfo } from './messages'; import {
updateThreadInfo,
updateThreadLocalState,
updateThreadReadState,
} from './threads';
function updateTopicsStore<T extends GlobalState>( const SAFE_MIN_PROPERTIES: (keyof ApiTopic)[] = [
'id',
'title',
'iconColor',
'iconEmojiId',
'date',
'fromId',
'isOwner',
'isClosed',
];
export function updateTopicsInfo<T extends GlobalState>(
global: T, chatId: string, update: Partial<TopicsInfo>, global: T, chatId: string, update: Partial<TopicsInfo>,
) { ) {
const info = global.chats.topicsInfoById[chatId] || {}; const info = global.chats.topicsInfoById[chatId] || {};
@ -35,7 +50,7 @@ export function updateListedTopicIds<T extends GlobalState>(
global: T, chatId: string, topicIds: number[], global: T, chatId: string, topicIds: number[],
): T { ): T {
const listedIds = selectTopicsInfo(global, chatId)?.listedTopicIds || []; const listedIds = selectTopicsInfo(global, chatId)?.listedTopicIds || [];
return updateTopicsStore(global, chatId, { return updateTopicsInfo(global, chatId, {
listedTopicIds: unique([ listedTopicIds: unique([
...listedIds, ...listedIds,
...topicIds, ...topicIds,
@ -43,35 +58,6 @@ export function updateListedTopicIds<T extends GlobalState>(
}); });
} }
export function updateTopics<T extends GlobalState>(
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<T extends GlobalState>( export function updateTopic<T extends GlobalState>(
global: T, chatId: string, topicId: number, update: Partial<ApiTopic>, global: T, chatId: string, topicId: number, update: Partial<ApiTopic>,
): T { ): T {
@ -82,29 +68,46 @@ export function updateTopic<T extends GlobalState>(
const topic = selectTopic(global, chatId, topicId); const topic = selectTopic(global, chatId, topicId);
const oldTopics = selectTopics(global, chatId); const oldTopics = selectTopics(global, chatId);
const safeUpdate = update.isMin ? pick(update, SAFE_MIN_PROPERTIES) : update;
const updatedTopic = { const updatedTopic = {
...topic, ...topic,
...update, ...safeUpdate,
} as ApiTopic; } as ApiTopic;
if (!updatedTopic.id) return global; if (!updatedTopic.id) return global;
global = updateTopicsStore(global, chatId, { global = updateTopicsInfo(global, chatId, {
topicsById: { topicsById: {
...oldTopics, ...oldTopics,
[topicId]: updatedTopic, [topicId]: updatedTopic,
}, },
}); });
global = updateThread(global, chatId, updatedTopic.id, { global = updateThreadLocalState(global, chatId, updatedTopic.id, {
firstMessageId: updatedTopic.id, firstMessageId: updatedTopic.id,
}); });
global = updateThreadInfo(global, chatId, updatedTopic.id, { return global;
lastMessageId: updatedTopic.lastMessageId, }
threadId: updatedTopic.id,
chatId, export function updateTopicWithState<T extends GlobalState>(
}); 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; return global;
} }
@ -115,25 +118,17 @@ export function deleteTopic<T extends GlobalState>(
const topics = selectTopics(global, chatId); const topics = selectTopics(global, chatId);
if (!topics) return global; if (!topics) return global;
global = updateTopicsStore(global, chatId, { global = updateTopicsInfo(global, chatId, {
topicsById: omit(topics, [topicId]), topicsById: omit(topics, [topicId]),
}); });
return global; return global;
} }
export function updateTopicLastMessageId<T extends GlobalState>(
global: T, chatId: string, threadId: number, lastMessageId: number,
) {
return updateTopic(global, chatId, threadId, {
lastMessageId,
});
}
export function replacePinnedTopicIds<T extends GlobalState>( export function replacePinnedTopicIds<T extends GlobalState>(
global: T, chatId: string, pinnedTopicIds: number[], global: T, chatId: string, pinnedTopicIds: number[],
) { ) {
return updateTopicsStore(global, chatId, { return updateTopicsInfo(global, chatId, {
orderedPinnedTopicIds: pinnedTopicIds, orderedPinnedTopicIds: pinnedTopicIds,
}); });
} }

View File

@ -12,9 +12,7 @@ import type {
import type { import type {
ChatTranslatedMessages, ChatTranslatedMessages,
MessageListType, MessageListType,
TabThread,
TextSummary, TextSummary,
Thread,
ThreadId, ThreadId,
} from '../../types'; } from '../../types';
import type { IAllowedAttachmentOptions } from '../helpers'; import type { IAllowedAttachmentOptions } from '../helpers';
@ -24,7 +22,8 @@ import type {
import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types'; import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types';
import { import {
ANONYMOUS_USER_ID, GENERAL_TOPIC_ID, SERVICE_NOTIFICATIONS_USER_ID, GENERAL_TOPIC_ID,
SERVICE_NOTIFICATIONS_USER_ID,
WEB_APP_PLATFORM, WEB_APP_PLATFORM,
} from '../../config'; } from '../../config';
import { IS_TRANSLATION_SUPPORTED } from '../../util/browser/windowEnvironment'; import { IS_TRANSLATION_SUPPORTED } from '../../util/browser/windowEnvironment';
@ -86,7 +85,17 @@ import { selectPeer, selectPeerPaidMessagesStars } from './peers';
import { selectPeerStory } from './stories'; import { selectPeerStory } from './stories';
import { selectCustomEmoji, selectIsStickerFavorite } from './symbols'; import { selectCustomEmoji, selectIsStickerFavorite } from './symbols';
import { selectTabState } from './tabs'; 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 { import {
selectBot, selectUser, selectUserStatus, selectBot, selectUser, selectUserStatus,
} from './users'; } from './users';
@ -121,45 +130,8 @@ export function selectChatScheduledMessages<T extends GlobalState>(global: T, ch
return global.scheduledMessages.byChatId[chatId]?.byId; return global.scheduledMessages.byChatId[chatId]?.byId;
} }
export function selectTabThreadParam<T extends GlobalState, K extends keyof TabThread>(
global: T,
chatId: string,
threadId: ThreadId,
key: K,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
return selectTabState(global, tabId).tabThreads[chatId]?.[threadId]?.[key];
}
export function selectThreadParam<T extends GlobalState, K extends keyof Thread>(
global: T,
chatId: string,
threadId: ThreadId,
key: K,
) {
return selectThread(global, chatId, threadId)?.[key];
}
export function selectThread<T extends GlobalState>(
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<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { export function selectListedIds<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'listedIds'); return selectThreadLocalStateParam(global, chatId, threadId, 'listedIds');
} }
export function selectOutlyingListByMessageId<T extends GlobalState>( export function selectOutlyingListByMessageId<T extends GlobalState>(
@ -178,7 +150,7 @@ export function selectOutlyingListByMessageId<T extends GlobalState>(
export function selectOutlyingLists<T extends GlobalState>( export function selectOutlyingLists<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, global: T, chatId: string, threadId: ThreadId,
) { ) {
return selectThreadParam(global, chatId, threadId, 'outlyingLists'); return selectThreadLocalStateParam(global, chatId, threadId, 'outlyingLists');
} }
export function selectCurrentMessageIds<T extends GlobalState>( export function selectCurrentMessageIds<T extends GlobalState>(
@ -205,79 +177,15 @@ export function selectViewportIds<T extends GlobalState>(
} }
export function selectPinnedIds<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { export function selectPinnedIds<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'pinnedIds'); return selectThreadLocalStateParam(global, chatId, threadId, 'pinnedIds');
} }
export function selectScheduledIds<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { export function selectScheduledIds<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'scheduledIds'); return selectThreadLocalStateParam(global, chatId, threadId, 'scheduledIds');
}
export function selectScrollOffset<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
return selectTabThreadParam(global, chatId, threadId, 'scrollOffset', tabId);
}
export function selectLastScrollOffset<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'lastScrollOffset');
}
export function selectEditingId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'editingId');
}
export function selectEditingDraft<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'editingDraft');
}
export function selectEditingScheduledId<T extends GlobalState>(global: T, chatId: string) {
return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledId');
}
export function selectEditingScheduledDraft<T extends GlobalState>(global: T, chatId: string) {
return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledDraft');
}
export function selectDraft<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'draft');
}
export function selectNoWebPage<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'noWebPage');
}
export function selectThreadInfo<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'threadInfo');
} }
export function selectFirstMessageId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { export function selectFirstMessageId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(global, chatId, threadId, 'firstMessageId'); return selectThreadLocalStateParam(global, chatId, threadId, 'firstMessageId');
}
export function selectReplyStack<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
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<T extends GlobalState>(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];
} }
export function selectIsMessageInCurrentMessageList<T extends GlobalState>( export function selectIsMessageInCurrentMessageList<T extends GlobalState>(
@ -391,18 +299,31 @@ export function selectIsMessageFocused<T extends GlobalState>(
return focusedId ? focusedId === message.id || focusedId === message.previousLocalId : false; return focusedId ? focusedId === message.id || focusedId === message.previousLocalId : false;
} }
export function selectIsMessageUnread<T extends GlobalState>(global: T, message: ApiMessage) { export function selectIsMessageUnread<T extends GlobalState>(
const { lastReadOutboxMessageId } = selectChat(global, message.chatId) || {}; global: T, chatId: string, threadId: ThreadId, messageId: number, messageListType: MessageListType,
return isMessageLocal(message) || !lastReadOutboxMessageId || lastReadOutboxMessageId < message.id; ) {
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<T extends GlobalState>( export function selectOutgoingStatus<T extends GlobalState>(
global: T, message: ApiMessage, isScheduledList = false, global: T, chatId: string, threadId: ThreadId, messageId: number, messageListType: MessageListType,
): ApiMessageOutgoingStatus { ): ApiMessageOutgoingStatus {
if (!selectIsMessageUnread(global, message) && !isScheduledList) { if (!selectIsMessageUnread(global, chatId, threadId, messageId, messageListType)) {
return 'read'; return 'read';
} }
const message = selectChatMessage(global, chatId, messageId);
if (!message) {
return 'failed'; // Should never happen
}
return getSendingState(message); return getSendingState(message);
} }
@ -514,24 +435,15 @@ export function selectFullWebPageFromMessage<T extends GlobalState>(global: T, m
return selectFullWebPage(global, message.content.webPage.id); return selectFullWebPage(global, message.content.webPage.id);
} }
export function selectTopicFromMessage<T extends GlobalState>(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; const MAX_MESSAGES_TO_DELETE_OWNER_TOPIC = 10;
export function selectCanDeleteOwnerTopic<T extends GlobalState>(global: T, chatId: string, topicId: number) { export function selectCanDeleteOwnerTopic<T extends GlobalState>(global: T, chatId: string, topicId: number) {
const topic = selectTopic(global, chatId, topicId); const topic = selectTopic(global, chatId, topicId);
if (topic && !topic.isOwner) return false; if (topic && !topic.isOwner) return false;
const thread = selectThread(global, chatId, topicId); const localThreadState = selectThreadLocalState(global, chatId, topicId);
if (!thread) return false; if (!localThreadState) return false;
const { listedIds } = thread; const { listedIds } = localThreadState;
if (!listedIds if (!listedIds
// Plus one for root message // Plus one for root message
|| listedIds.length + 1 >= MAX_MESSAGES_TO_DELETE_OWNER_TOPIC) { || listedIds.length + 1 >= MAX_MESSAGES_TO_DELETE_OWNER_TOPIC) {
@ -559,59 +471,6 @@ export function selectCanDeleteTopic<T extends GlobalState>(global: T, chatId: s
&& selectCanDeleteOwnerTopic(global, chat.id, topicId)); && selectCanDeleteOwnerTopic(global, chat.id, topicId));
} }
export function selectSavedDialogIdFromMessage<T extends GlobalState>(
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<T extends GlobalState>(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<T extends GlobalState>(global: T, message: ApiMessage, threadId: ThreadId) { export function selectCanReplyToMessage<T extends GlobalState>(global: T, message: ApiMessage, threadId: ThreadId) {
const chat = selectChat(global, message.chatId); const chat = selectChat(global, message.chatId);
const isRestricted = selectIsChatRestricted(global, message.chatId); const isRestricted = selectIsChatRestricted(global, message.chatId);
@ -917,37 +776,23 @@ export function selectUploadProgress<T extends GlobalState>(global: T, message:
} }
export function selectRealLastReadId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { export function selectRealLastReadId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
if (threadId === MAIN_THREAD_ID) { const readState = selectThreadReadState(global, chatId, threadId);
const chat = selectChat(global, chatId); if (!readState) {
if (!chat) { return undefined;
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);
} }
// `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<T extends GlobalState>( export function selectFirstUnreadId<T extends GlobalState>(
@ -961,8 +806,9 @@ export function selectFirstUnreadId<T extends GlobalState>(
} }
} else { } else {
const threadInfo = selectThreadInfo(global, chatId, threadId); const threadInfo = selectThreadInfo(global, chatId, threadId);
if (!threadInfo const readState = selectThreadReadState(global, chatId, threadId);
|| (threadInfo.lastMessageId !== undefined && threadInfo.lastMessageId === threadInfo.lastReadInboxMessageId)) { if (!threadInfo || !readState
|| (threadInfo.lastMessageId !== undefined && threadInfo.lastMessageId === readState.lastReadInboxMessageId)) {
return undefined; return undefined;
} }
} }

View File

@ -1,7 +1,7 @@
import type { GlobalState, TabArgs } from '../types'; import type { GlobalState, TabArgs } from '../types';
import { DEFAULT_GIFT_PROFILE_FILTER_OPTIONS } from '../../config'; import { DEFAULT_GIFT_PROFILE_FILTER_OPTIONS } from '../../config';
import arePropsShallowEqual from '../../util/arePropsShallowEqual'; import { areRecordsShallowEqual } from '../../util/areShallowEqual';
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
import { import {
getHasAdminRight, isChatAdmin, isChatChannel, getHasAdminRight, isChatAdmin, isChatChannel,
@ -96,7 +96,7 @@ export function selectIsGiftProfileFilterDefault<T extends GlobalState>(
global: T, global: T,
...[tabId = getCurrentTabId()]: TabArgs<T> ...[tabId = getCurrentTabId()]: TabArgs<T>
) { ) {
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<T extends GlobalState>( export function selectActiveGiftsCollectionId<T extends GlobalState>(

View File

@ -44,3 +44,7 @@ export function selectShouldHideReadMarks<T extends GlobalState>(global: T) {
export function selectSettingsKeys<T extends GlobalState>(global: T) { export function selectSettingsKeys<T extends GlobalState>(global: T) {
return global.settings.byKey; return global.settings.byKey;
} }
export function selectTimezones<T extends GlobalState>(global: T) {
return global.timezones?.byId;
}

View File

@ -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<T extends GlobalState, K extends keyof TabThread>(
global: T,
chatId: string,
threadId: ThreadId,
key: K,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
return selectTabState(global, tabId).tabThreads[chatId]?.[threadId]?.[key];
}
export function selectThreadInfo<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThread(global, chatId, threadId)?.threadInfo;
}
export function selectThreadReadState<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThread(global, chatId, threadId)?.readState;
}
export function selectThreadLocalStateParam<T extends GlobalState, K extends keyof ThreadLocalState>(
global: T,
chatId: string,
threadId: ThreadId,
key: K,
) {
return selectThreadLocalState(global, chatId, threadId)?.[key];
}
export function selectThread<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(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<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
return selectTabThreadParam(global, chatId, threadId, 'scrollOffset', tabId);
}
export function selectLastScrollOffset<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadLocalStateParam(global, chatId, threadId, 'lastScrollOffset');
}
export function selectEditingId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadLocalStateParam(global, chatId, threadId, 'editingId');
}
export function selectEditingDraft<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadLocalStateParam(global, chatId, threadId, 'editingDraft');
}
export function selectEditingScheduledId<T extends GlobalState>(global: T, chatId: string) {
return selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledId');
}
export function selectEditingScheduledDraft<T extends GlobalState>(global: T, chatId: string) {
return selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledDraft');
}
export function selectDraft<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadLocalStateParam(global, chatId, threadId, 'draft');
}
export function selectNoWebPage<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadLocalStateParam(global, chatId, threadId, 'noWebPage');
}
export function selectReplyStack<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
return selectTabThreadParam(global, chatId, threadId, 'replyStack', tabId);
}
export function selectSavedDialogIdFromMessage<T extends GlobalState>(
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<T extends GlobalState>(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;
}

View File

@ -1,6 +1,10 @@
import type { ApiMessage } from '../../api/types';
import type { ThreadId, TopicsInfo } from '../../types'; import type { ThreadId, TopicsInfo } from '../../types';
import type { GlobalState } from '../types'; import type { GlobalState } from '../types';
import { selectChat } from './chats';
import { selectThreadIdFromMessage } from './threads';
export function selectTopicsInfo<T extends GlobalState>(global: T, chatId: string): TopicsInfo | undefined { export function selectTopicsInfo<T extends GlobalState>(global: T, chatId: string): TopicsInfo | undefined {
return global.chats.topicsInfoById[chatId]; return global.chats.topicsInfoById[chatId];
} }
@ -12,3 +16,12 @@ export function selectTopics<T extends GlobalState>(global: T, chatId: string) {
export function selectTopic<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { export function selectTopic<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectTopicsInfo(global, chatId)?.topicsById?.[threadId]; return selectTopicsInfo(global, chatId)?.topicsById?.[threadId];
} }
export function selectTopicFromMessage<T extends GlobalState>(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);
}

View File

@ -529,17 +529,13 @@ export interface ActionPayloads {
maxId: number; maxId: number;
} & WithTabId; } & WithTabId;
markMessagesRead: { markMessagesRead: {
chatId: string;
messageIds: number[]; messageIds: number[];
shouldFetchUnreadReactions?: boolean; };
} & WithTabId;
loadMessage: { loadMessage: {
chatId: string; chatId: string;
messageId: number; messageId: number;
replyOriginForId?: number; replyOriginForId?: number;
threadUpdate?: {
lastMessageId: number;
isDeleting?: boolean;
};
}; };
loadMessagesById: { loadMessagesById: {
chatId: string; chatId: string;
@ -1410,12 +1406,14 @@ export interface ActionPayloads {
threadId: ThreadId; threadId: ThreadId;
type: MessageListType; type: MessageListType;
}; };
fetchUnreadMentions: { loadUnreadMentions: {
chatId: string; chatId: string;
threadId?: ThreadId;
offsetId?: number; offsetId?: number;
}; };
fetchUnreadReactions: { loadUnreadReactions: {
chatId: string; chatId: string;
threadId?: ThreadId;
offsetId?: number; offsetId?: number;
}; };
scheduleForViewsIncrement: { scheduleForViewsIncrement: {
@ -1441,10 +1439,17 @@ export interface ActionPayloads {
quickReplyId: number; quickReplyId: number;
}; };
animateUnreadReaction: { animateUnreadReaction: {
chatId: string;
messageIds: number[]; messageIds: number[];
} & WithTabId; } & WithTabId;
focusNextReaction: WithTabId | undefined; focusNextReaction: {
focusNextMention: WithTabId | undefined; chatId: string;
threadId?: ThreadId;
} & WithTabId;
focusNextMention: {
chatId: string;
threadId?: ThreadId;
} & WithTabId;
readAllReactions: { readAllReactions: {
chatId: string; chatId: string;
threadId?: ThreadId; threadId?: ThreadId;
@ -1456,7 +1461,7 @@ export interface ActionPayloads {
markMentionsRead: { markMentionsRead: {
chatId: string; chatId: string;
messageIds: number[]; messageIds: number[];
} & WithTabId; };
copyMessageLink: { copyMessageLink: {
chatId: string; chatId: string;
messageId: number; messageId: number;

View File

@ -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 type { GlobalState } from '../../global/types';
import areShallowEqual from '../../util/areShallowEqual';
import { createSignal, type Signal, type SignalSetter } from '../../util/signals';
import useDerivedState from '../useDerivedState'; import useDerivedState from '../useDerivedState';
import useSelectorSignal from './useSelectorSignal'; import useSyncEffect from '../useSyncEffect';
type Selector<T> = (global: GlobalState) => T; type Selector<T> = (global: GlobalState) => T;
type EqualityFn<T> = (oldValue: T, newValue: T) => boolean;
export default function useSelector<T>(selector: Selector<T>) { interface State<T> {
const selectorSignal = useSelectorSignal(selector); clientsCount: number;
return useDerivedState(selectorSignal); getter: Signal<T>;
setter: SignalSetter<T>;
} }
const bySelector = new Map<Selector<unknown>, State<unknown>>();
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<T>(selector: Selector<T>): Signal<T> {
let state = bySelector.get(selector) as State<T> | undefined;
if (!state) {
const [getter, setter] = createSignal(selector(currentGlobal));
state = { clientsCount: 0, getter, setter };
bySelector.set(selector, state as State<unknown>);
}
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<T>(selector: Selector<T>) {
const selectorSignal = useSelectorSignal(selector);
return useDerivedState(selectorSignal, [selectorSignal, selector]);
}
export function useSelectorSignalWithEquality<T>(
selector: Selector<T>,
equalityFn: EqualityFn<T>,
) {
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<T>(
selector: Selector<T>,
equalityFn: EqualityFn<T>,
) {
const signal = useSelectorSignalWithEquality(selector, equalityFn);
return useDerivedState(signal);
}
export function useShallowSelectorSignal<T extends AnyLiteral | undefined>(selector: Selector<T>) {
return useSelectorSignalWithEquality(selector, areShallowEqual);
}
export function useShallowSelector<T extends AnyLiteral | undefined>(selector: Selector<T>) {
return useSelectorWithEquality(selector, areShallowEqual);
}
export default useSelector;

View File

@ -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<T> = (global: GlobalState) => T;
interface State<T> {
clientsCount: number;
getter: Signal<T>;
setter: SignalSetter<T>;
}
const bySelector = new Map<Selector<unknown>, State<unknown>>();
addCallback((global: GlobalState) => {
for (const [selector, { setter }] of bySelector) {
setter(selector(global));
}
});
function useSelectorSignal<T>(selector: Selector<T>): Signal<T> {
let state = bySelector.get(selector) as State<T> | undefined;
if (!state) {
const [getter, setter] = createSignal(selector(getGlobal()));
state = { clientsCount: 0, getter, setter };
bySelector.set(selector, state as State<unknown>);
}
useSyncEffect(() => {
const state2 = bySelector.get(selector)!;
state2.clientsCount++;
return () => {
state2.clientsCount--;
if (!state2.clientsCount) {
bySelector.delete(selector);
}
};
}, [selector]);
return state.getter;
}
export default useSelectorSignal;

View File

@ -1,15 +1,18 @@
import { useMemo } from '../lib/teact/teact'; import { useCallback, useMemo } from '../lib/teact/teact';
import { getActions } from '../global'; import { getActions } from '../global';
import type { ApiChat, ApiTopic, ApiUser } from '../api/types';
import type { MenuItemContextAction } from '../components/ui/ListItem'; 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 { SERVICE_NOTIFICATIONS_USER_ID } from '../config';
import { getCanDeleteChat, isChatArchived, isChatChannel, isChatGroup } from '../global/helpers'; import { getCanDeleteChat, isChatArchived, isChatChannel, isChatGroup } from '../global/helpers';
import { selectThreadReadState } from '../global/selectors/threads';
import { IS_TAURI } from '../util/browser/globalEnvironment'; import { IS_TAURI } from '../util/browser/globalEnvironment';
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../util/browser/windowEnvironment'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../util/browser/windowEnvironment';
import { isUserId } from '../util/entities/ids'; 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'; import useLang from './useLang';
const useChatContextActions = ({ const useChatContextActions = ({
@ -22,7 +25,7 @@ const useChatContextActions = ({
isSavedDialog, isSavedDialog,
currentUserId, currentUserId,
isPreview, isPreview,
topics, topicIds,
handleDelete, handleDelete,
handleMute, handleMute,
handleUnmute, handleUnmute,
@ -38,18 +41,42 @@ const useChatContextActions = ({
isSavedDialog?: boolean; isSavedDialog?: boolean;
currentUserId?: string; currentUserId?: string;
isPreview?: boolean; isPreview?: boolean;
topics?: Record<number, ApiTopic>; topicIds?: number[];
handleDelete?: NoneToVoidFunction; handleDelete?: NoneToVoidFunction;
handleMute?: NoneToVoidFunction; handleMute?: NoneToVoidFunction;
handleUnmute?: NoneToVoidFunction; handleUnmute?: NoneToVoidFunction;
handleChatFolderChange: NoneToVoidFunction; handleChatFolderChange: NoneToVoidFunction;
handleReport?: NoneToVoidFunction; handleReport?: NoneToVoidFunction;
}, isInSearch = false) => { }, isInSearch = false) => {
const {
toggleChatPinned,
toggleSavedDialogPinned,
toggleChatArchived,
markChatMessagesRead,
markChatUnread,
openChatInNewTab,
openQuickPreview,
} = getActions();
const lang = useLang(); const lang = useLang();
const { isSelf } = user || {}; const { isSelf } = user || {};
const isServiceNotifications = user?.id === SERVICE_NOTIFICATIONS_USER_ID; 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(() => { const deleteTitle = useMemo(() => {
if (!chat) return undefined; if (!chat) return undefined;
@ -72,21 +99,11 @@ const useChatContextActions = ({
return lang('GroupLeaveGroup'); return lang('GroupLeaveGroup');
}, [chat, isSavedDialog, lang]); }, [chat, isSavedDialog, lang]);
return useMemo(() => { const preparedActions = useMemo(() => {
if (!chat || isPreview) { if (!chat || isPreview) {
return undefined; return undefined;
} }
const {
toggleChatPinned,
toggleSavedDialogPinned,
toggleChatArchived,
markChatMessagesRead,
markChatUnread,
openChatInNewTab,
openQuickPreview,
} = getActions();
const actionOpenInNewTab = IS_OPEN_IN_NEW_TAB_SUPPORTED && { const actionOpenInNewTab = IS_OPEN_IN_NEW_TAB_SUPPORTED && {
title: IS_TAURI ? lang('ChatListOpenInNewWindow') : lang('ChatListOpenInNewTab'), title: IS_TAURI ? lang('ChatListOpenInNewWindow') : lang('ChatListOpenInNewTab'),
icon: 'open-in-new-tab', icon: 'open-in-new-tab',
@ -97,7 +114,7 @@ const useChatContextActions = ({
openChatInNewTab({ chatId: chat.id }); openChatInNewTab({ chatId: chat.id });
} }
}, },
}; } satisfies MenuItemContextAction;
const actionQuickPreview = !isSavedDialog && !chat.isForum && { const actionQuickPreview = !isSavedDialog && !chat.isForum && {
title: lang('QuickPreview'), title: lang('QuickPreview'),
@ -107,7 +124,7 @@ const useChatContextActions = ({
id: chat.id, id: chat.id,
}); });
}, },
}; } satisfies MenuItemContextAction;
const togglePinned = () => { const togglePinned = () => {
if (isSavedDialog) { if (isSavedDialog) {
@ -117,7 +134,7 @@ const useChatContextActions = ({
} }
}; };
const actionPin = isPinned const actionPin: MenuItemContextAction = isPinned
? { ? {
title: lang('ChatListUnpinFromTop'), title: lang('ChatListUnpinFromTop'),
icon: 'unpin', icon: 'unpin',
@ -129,12 +146,12 @@ const useChatContextActions = ({
handler: togglePinned, handler: togglePinned,
}; };
const actionDelete = { const actionDelete = deleteTitle ? {
title: deleteTitle, title: deleteTitle,
icon: 'delete', icon: 'delete',
destructive: true, destructive: true,
handler: handleDelete, handler: handleDelete,
}; } satisfies MenuItemContextAction : undefined;
if (isSavedDialog) { if (isSavedDialog) {
return compact([actionOpenInNewTab, actionQuickPreview, actionPin, actionDelete]) as MenuItemContextAction[]; return compact([actionOpenInNewTab, actionQuickPreview, actionPin, actionDelete]) as MenuItemContextAction[];
@ -144,9 +161,9 @@ const useChatContextActions = ({
title: lang('ChatListContextAddToFolder'), title: lang('ChatListContextAddToFolder'),
icon: 'folder', icon: 'folder',
handler: handleChatFolderChange, handler: handleChatFolderChange,
} : undefined; } satisfies MenuItemContextAction : undefined;
const actionMute = isMuted const actionMute: MenuItemContextAction = isMuted
? { ? {
title: lang('ChatsUnmute'), title: lang('ChatsUnmute'),
icon: 'unmute', icon: 'unmute',
@ -164,46 +181,51 @@ const useChatContextActions = ({
]) as MenuItemContextAction[]; ]) as MenuItemContextAction[];
} }
const actionMaskAsRead = ( const actionMarkAsRead = (
chat.unreadCount || chat.hasUnreadMark || Object.values(topics || {}).some(({ unreadCount }) => unreadCount) 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'), title: lang('ChatListContextMarkAsUnread'), icon: 'unread', handler: () => markChatUnread({ id: chat.id }),
icon: 'readchats', } satisfies MenuItemContextAction
handler: () => markChatMessagesRead({ id: chat.id }),
} : undefined;
const actionMarkAsUnread = !(chat.unreadCount || chat.hasUnreadMark) && !chat.isForum
? { title: lang('ChatListContextMaskAsUnread'), icon: 'unread', handler: () => markChatUnread({ id: chat.id }) }
: undefined; : undefined;
const actionArchive = isChatArchived(chat) const actionArchive: MenuItemContextAction = isChatArchived(chat)
? { title: lang('Unarchive'), icon: 'unarchive', handler: () => toggleChatArchived({ id: chat.id }) } ? { title: lang('Unarchive'), icon: 'unarchive', handler: () => toggleChatArchived({ id: chat.id }) }
: { title: lang('Archive'), icon: 'archive', handler: () => toggleChatArchived({ id: chat.id }) }; : { title: lang('Archive'), icon: 'archive', handler: () => toggleChatArchived({ id: chat.id }) };
const canReport = handleReport && !user && (isChatChannel(chat) || isChatGroup(chat)); const canReport = handleReport && !user && (isChatChannel(chat) || isChatGroup(chat));
const actionReport = canReport const actionReport = canReport
? { title: lang('ReportPeerReport'), icon: 'flag', handler: handleReport } ? { title: lang('ReportPeerReport'), icon: 'flag', handler: handleReport } satisfies MenuItemContextAction
: undefined; : undefined;
const isInFolder = folderId !== undefined; const isInFolder = folderId !== undefined;
return compact([ return compact<MenuItemContextAction>([
actionOpenInNewTab, actionOpenInNewTab,
actionQuickPreview, actionQuickPreview,
actionAddToFolder, actionAddToFolder,
actionMaskAsRead, actionMarkAsRead,
actionMarkAsUnread, actionMarkAsUnread,
actionPin, actionPin,
!isSelf && actionMute, !isSelf && actionMute,
!isSelf && !isServiceNotifications && !isInFolder && actionArchive, !isSelf && !isServiceNotifications && !isInFolder && actionArchive,
actionReport, actionReport,
actionDelete, actionDelete,
]) as MenuItemContextAction[]; ]);
}, [ }, [
chat, user, canChangeFolder, lang, handleChatFolderChange, isPinned, isInSearch, isMuted, currentUserId, chat, isPreview, lang, isSavedDialog, isPinned, deleteTitle, handleDelete, canChangeFolder,
handleDelete, handleMute, handleReport, folderId, isSelf, isServiceNotifications, isSavedDialog, deleteTitle, handleChatFolderChange, isMuted, handleUnmute, handleMute, isInSearch, chatReadState, topicsReadStates,
isPreview, topics, handleUnmute, handleReport, user, folderId, isSelf, isServiceNotifications, currentUserId,
]); ]);
return preparedActions;
}; };
export default useChatContextActions; export default useChatContextActions;

View File

@ -1,21 +1,42 @@
import { useRef, useUnmountCleanup } from '../lib/teact/teact'; import { useRef, useUnmountCleanup } from '../lib/teact/teact';
import { requestMeasure } from '../lib/fasterdom/fasterdom';
import { cleanupEffect, isSignal } from '../util/signals'; import { cleanupEffect, isSignal } from '../util/signals';
export function useSignalEffect(effect: NoneToVoidFunction, dependencies: readonly any[]) { export function useSignalEffect(effect: NoneToVoidFunction, dependencies: readonly any[]) {
// The is extracted from `useEffectOnce` to run before all effects // This runs before all effects
const isFirstRun = useRef(true); const prevDepsRef = useRef<readonly unknown[]>();
if (isFirstRun.current) { const subscribedEffectRef = useRef<NoneToVoidFunction>();
isFirstRun.current = false;
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)) { if (isSignal(dependency)) {
dependency.subscribe(effect); dependency.subscribe(effect);
} }
}); });
const currentEffect = effect;
requestMeasure(() => {
if (subscribedEffectRef.current !== currentEffect) return;
currentEffect();
});
} }
useUnmountCleanup(() => { useUnmountCleanup(() => {
cleanupEffect(effect); if (subscribedEffectRef.current) {
cleanupEffect(subscribedEffectRef.current);
}
}); });
} }

View File

@ -2,7 +2,7 @@
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { DEBUG, DEBUG_MORE } from '../../config'; import { DEBUG, DEBUG_MORE } from '../../config';
import { logUnequalProps } from '../../util/arePropsShallowEqual'; import { logUnequalProps } from '../../util/areShallowEqual';
import { incrementOverlayCounter } from '../../util/debugOverlay'; import { incrementOverlayCounter } from '../../util/debugOverlay';
import { orderBy } from '../../util/iteratees'; import { orderBy } from '../../util/iteratees';
import safeExec from '../../util/safeExec'; import safeExec from '../../util/safeExec';

View File

@ -1,7 +1,7 @@
import type { FC, FC_withDebug, Props } from './teact'; import type { FC, FC_withDebug, Props } from './teact';
import { DEBUG, DEBUG_MORE } from '../../config'; import { DEBUG, DEBUG_MORE } from '../../config';
import arePropsShallowEqual, { logUnequalProps } from '../../util/arePropsShallowEqual'; import { areRecordsShallowEqual, logUnequalProps } from '../../util/areShallowEqual';
import Deferred from '../../util/Deferred'; import Deferred from '../../util/Deferred';
import { handleError } from '../../util/handleError'; import { handleError } from '../../util/handleError';
import { orderBy } from '../../util/iteratees'; 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) { if (DEBUG_MORE) {
logUnequalProps( logUnequalProps(
mappedProps!, mappedProps!,
@ -296,7 +296,7 @@ export function withUntypedGlobal<OwnProps extends AnyLiteral>(
} }
if ( if (
(!container.mappedProps || !arePropsShallowEqual(container.ownProps, props)) (!container.mappedProps || !areRecordsShallowEqual(container.ownProps, props))
&& activateContainer(container, currentGlobal, props) && activateContainer(container, currentGlobal, props)
) { ) {
try { try {

View File

@ -606,25 +606,47 @@ export interface TabThread {
viewportIds?: number[]; 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; lastScrollOffset?: number;
lastViewportIds?: number[]; lastViewportIds?: number[];
listedIds?: number[]; listedIds?: number[];
outlyingLists?: number[][]; outlyingLists?: number[][];
pinnedIds?: number[]; pinnedIds?: number[];
scheduledIds?: number[]; scheduledIds?: number[];
firstMessageId?: number;
editingId?: number; editingId?: number;
editingScheduledId?: number; editingScheduledId?: number;
editingDraft?: ApiFormattedText; editingDraft?: ApiFormattedText;
editingScheduledDraft?: ApiFormattedText; editingScheduledDraft?: ApiFormattedText;
draft?: ApiDraft; draft?: ApiDraft;
noWebPage?: boolean; noWebPage?: boolean;
threadInfo?: ApiThreadInfo;
firstMessageId?: number;
typingStatus?: ApiTypingStatus; typingStatus?: ApiTypingStatus;
typingDraftIdByRandomId?: Record<string, number>; typingDraftIdByRandomId?: Record<string, number>;
} }
export interface Thread {
localState: ThreadLocalState;
threadInfo: ApiThreadInfo;
readState: ThreadReadState;
}
export interface ServiceNotification { export interface ServiceNotification {
id: number; id: number;
message: ApiMessage; message: ApiMessage;

View File

@ -1059,8 +1059,8 @@ export interface LangPair {
'ChatListPinToTop': undefined; 'ChatListPinToTop': undefined;
'ChatListOpenInNewWindow': undefined; 'ChatListOpenInNewWindow': undefined;
'ChatListOpenInNewTab': undefined; 'ChatListOpenInNewTab': undefined;
'ChatListContextMaskAsRead': undefined; 'ChatListContextMarkAsRead': undefined;
'ChatListContextMaskAsUnread': undefined; 'ChatListContextMarkAsUnread': undefined;
'ChatListContextAddToFolder': undefined; 'ChatListContextAddToFolder': undefined;
'Unarchive': undefined; 'Unarchive': undefined;
'Archive': undefined; 'Archive': undefined;
@ -1886,6 +1886,7 @@ export interface LangPair {
'SettingsBirthday': undefined; 'SettingsBirthday': undefined;
'BotReadTextFromClipboardTitle': undefined; 'BotReadTextFromClipboardTitle': undefined;
'BotReadTextFromClipboardConfirm': undefined; 'BotReadTextFromClipboardConfirm': undefined;
'ChatInfoForumTopic': undefined;
'DiceToastSend': undefined; 'DiceToastSend': undefined;
'ChatTypePrivate': undefined; 'ChatTypePrivate': undefined;
'ChatTypeGroup': undefined; 'ChatTypeGroup': undefined;

View File

@ -1,4 +1,4 @@
export default function arePropsShallowEqual(currentProps: AnyLiteral, newProps: AnyLiteral) { export function areRecordsShallowEqual(currentProps: AnyLiteral, newProps: AnyLiteral) {
if (currentProps === newProps) { if (currentProps === newProps) {
return true; return true;
} }
@ -25,6 +25,27 @@ export default function arePropsShallowEqual(currentProps: AnyLiteral, newProps:
return true; 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<T extends AnyLiteral | unknown[] | undefined>(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 = '') { export function logUnequalProps(currentProps: AnyLiteral, newProps: AnyLiteral, msg: string, debugKey = '') {
const currentKeys = Object.keys(currentProps); const currentKeys = Object.keys(currentProps);
const currentKeysLength = currentKeys.length; const currentKeysLength = currentKeys.length;

View File

@ -2,11 +2,12 @@ import { onFullyIdle } from '../lib/teact/teact';
import { addCallback } from '../lib/teact/teactn'; import { addCallback } from '../lib/teact/teactn';
import { addActionHandler, getGlobal } from '../global'; import { addActionHandler, getGlobal } from '../global';
import type {
ApiChat, ApiChatFolder, ApiNotifyPeerType, ApiPeerNotifySettings, ApiUser,
} from '../api/types';
import type { GlobalState } from '../global/types'; import type { GlobalState } from '../global/types';
import type { CallbackManager } from './callbacks'; import type { CallbackManager } from './callbacks';
import {
type ApiChat, type ApiChatFolder, type ApiNotifyPeerType, type ApiPeerNotifySettings, type ApiUser,
MAIN_THREAD_ID,
} from '../api/types';
import { import {
ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, DEBUG, SAVED_FOLDER_ID, SERVICE_NOTIFICATIONS_USER_ID, ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, DEBUG, SAVED_FOLDER_ID, SERVICE_NOTIFICATIONS_USER_ID,
@ -19,7 +20,8 @@ import {
selectTabState, selectTabState,
selectTopics, selectTopics,
} from '../global/selectors'; } from '../global/selectors';
import arePropsShallowEqual from './arePropsShallowEqual'; import { selectDraft, selectThreadReadState } from '../global/selectors/threads';
import { areRecordsShallowEqual } from './areShallowEqual';
import { createCallbackManager } from './callbacks'; import { createCallbackManager } from './callbacks';
import { areSortedArraysEqual, unique } from './iteratees'; import { areSortedArraysEqual, unique } from './iteratees';
import { throttle } from './schedulers'; import { throttle } from './schedulers';
@ -486,7 +488,7 @@ function updateChats(
isRemovedFromSaved, isRemovedFromSaved,
); );
if (!areFoldersChanged && currentSummary && arePropsShallowEqual(newSummary, currentSummary)) { if (!areFoldersChanged && currentSummary && areRecordsShallowEqual(newSummary, currentSummary)) {
return; return;
} }
@ -533,16 +535,21 @@ function buildChatSummary<T extends GlobalState>(
): ChatSummary { ): ChatSummary {
const { const {
id, type, isNotJoined, migratedTo, folderId, id, type, isNotJoined, migratedTo, folderId,
unreadCount: chatUnreadCount, unreadMentionsCount: chatUnreadMentionsCount, hasUnreadMark,
isForum, isForum,
} = chat; } = chat;
const draft = selectDraft(global, id, MAIN_THREAD_ID);
const isRestricted = selectIsChatRestricted(global, id); const isRestricted = selectIsChatRestricted(global, id);
const topics = selectTopics(global, chat.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 const { unreadCount, unreadMentionsCount } = isForum
? Object.values(topics || {}).reduce((acc, topic) => { ? Object.values(topics || {}).reduce((acc, topic) => {
acc.unreadCount += topic.unreadCount; const topicReadState = selectThreadReadState(global, chat.id, topic.id);
acc.unreadMentionsCount += topic.unreadMentionsCount; acc.unreadCount += topicReadState?.unreadCount || 0;
acc.unreadMentionsCount += topicReadState?.unreadMentionsCount || 0;
return acc; return acc;
}, { unreadCount: 0, unreadMentionsCount: 0 }) }, { unreadCount: 0, unreadMentionsCount: 0 })
@ -554,7 +561,7 @@ function buildChatSummary<T extends GlobalState>(
!lastMessage || lastMessage.content.action?.type === 'historyClear' !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 lastMessageInSaved = selectChatLastMessage(global, chat.id, 'saved');
const orderInSaved = lastMessageInSaved?.date || 0; const orderInSaved = lastMessageInSaved?.date || 0;
@ -731,7 +738,7 @@ function updateResults(affectedFolderIds: number[]) {
const newUnreadCounters = buildFolderUnreadCounters(folderId); const newUnreadCounters = buildFolderUnreadCounters(folderId);
if (!wasUnreadCountersChanged) { if (!wasUnreadCountersChanged) {
wasUnreadCountersChanged = ( wasUnreadCountersChanged = (
!currentUnreadCounters || !arePropsShallowEqual(newUnreadCounters, currentUnreadCounters) !currentUnreadCounters || !areRecordsShallowEqual(newUnreadCounters, currentUnreadCounters)
); );
} }
results.unreadCountersByFolderId[folderId] = newUnreadCounters; results.unreadCountersByFolderId[folderId] = newUnreadCounters;

View File

@ -14,7 +14,7 @@ export function buildCollectionByKey<T extends AnyLiteral>(collection: T[], key:
}, {}); }, {});
} }
export function buildCollectionByCallback<T extends AnyLiteral, K extends number | string, R>( export function buildCollectionByCallback<T, K extends number | string, R>(
collection: T[], collection: T[],
callback: (member: T) => [K, R], callback: (member: T) => [K, R],
) { ) {
@ -36,6 +36,19 @@ export function mapValues<R, M>(
}, {}); }, {});
} }
export function mapTruthyValues<R, M>(
byKey: CollectionByKey<M>,
callback: (member: M, key: string, index: number, originalByKey: CollectionByKey<M>) => R | Falsy,
): CollectionByKey<R> {
return Object.keys(byKey).reduce((newByKey: CollectionByKey<R>, key, index) => {
const value = callback(byKey[key], key, index, byKey);
if (value) {
newByKey[key] = value;
}
return newByKey;
}, {});
}
export function pick<T, K extends keyof T>(object: T, keys: K[]) { export function pick<T, K extends keyof T>(object: T, keys: K[]) {
return keys.reduce((result, key) => { return keys.reduce((result, key) => {
result[key] = object[key]; result[key] = object[key];
@ -109,7 +122,7 @@ export function uniqueByField<T>(array: T[], field: keyof T): T[] {
return [...new Map(array.map((item) => [item[field], item])).values()]; return [...new Map(array.map((item) => [item[field], item])).values()];
} }
export function compact<T>(array: T[]) { export function compact<T>(array: (T | Falsy)[]): T[] {
return array.filter(Boolean); return array.filter(Boolean);
} }