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
* **Use** `withGlobal` (a `mapStateToProps` helper) to pull in state.
* **Avoid** the experimental `useSelector` API.
* There is an experimental `useSelector` API available.
* **Use** `getGlobal` **only** inside hooks for one-off reads (it's non-reactive).
### 2. Performance

View File

@ -1,28 +1,31 @@
import { Api as GramJs } from '../../../lib/gramjs';
import type { Entity } from '../../../lib/gramjs/types';
import type {
ApiBotCommand,
ApiChat,
ApiChatAdminRights,
ApiChatBannedRights,
ApiChatFolder,
ApiChatInviteImporter,
ApiChatInviteInfo,
ApiChatlistExportedInvite,
ApiChatlistInvite,
ApiChatMember,
ApiChatReactions,
ApiExportedInvite,
ApiMissingInvitedUser,
ApiRestrictionReason,
ApiSendAsPeerId,
ApiSponsoredMessageReportResult,
ApiSponsoredPeer,
ApiStarsSubscriptionPricing,
import type { ThreadReadState } from '../../../types';
import {
type ApiBotCommand,
type ApiChat,
type ApiChatAdminRights,
type ApiChatBannedRights,
type ApiChatFolder,
type ApiChatInviteImporter,
type ApiChatInviteInfo,
type ApiChatlistExportedInvite,
type ApiChatlistInvite,
type ApiChatMember,
type ApiChatReactions,
type ApiExportedInvite,
type ApiMissingInvitedUser,
type ApiRestrictionReason,
type ApiSendAsPeerId,
type ApiSponsoredMessageReportResult,
type ApiSponsoredPeer,
type ApiStarsSubscriptionPricing,
type ApiThreadInfo,
MAIN_THREAD_ID,
} from '../../types';
import { pickTruthy } from '../../../util/iteratees';
import { omitUndefined, pickTruthy } from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers';
import { getServerTimeOffset } from '../../../util/serverTime';
import { addPhotoToLocalDb, addUserToLocalDb } from '../helpers/localDb';
@ -144,23 +147,15 @@ export function buildApiChatFromDialog(
peerEntity: GramJs.TypeUser | GramJs.TypeChat,
): ApiChat {
const {
peer, folderId, unreadMark, unreadCount, unreadMentionsCount, unreadReactionsCount,
readOutboxMaxId, readInboxMaxId, draft, viewForumAsMessages,
peer, folderId, viewForumAsMessages,
} = dialog;
return {
id: getApiChatIdFromMtpPeer(peer),
...(folderId && { folderId }),
folderId,
type: getApiChatTypeFromPeerEntity(peerEntity),
title: getApiChatTitleFromMtpPeer(peer, peerEntity),
lastReadOutboxMessageId: readOutboxMaxId,
lastReadInboxMessageId: readInboxMaxId,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
...(unreadMark && { hasUnreadMark: true }),
...(draft instanceof GramJs.DraftMessage && { draftDate: draft.date }),
...(viewForumAsMessages && { isForumAsMessages: true }),
isForumAsMessages: viewForumAsMessages,
...buildApiChatFieldsFromPeerEntity(peerEntity),
};
}
@ -705,3 +700,38 @@ export function buildApiSponsoredPeer(sponsoredPeer: GramJs.SponsoredPeer): ApiS
sponsorInfo,
};
}
export function buildThreadReadState(
input: GramJs.Dialog | GramJs.MonoForumDialog | GramJs.ForumTopic | GramJs.messages.DiscussionMessage,
): ThreadReadState {
const { unreadCount, readInboxMaxId, readOutboxMaxId } = input;
const dialog = input instanceof GramJs.Dialog ? input : undefined;
const monoForumDialog = input instanceof GramJs.MonoForumDialog ? input : undefined;
const forumTopic = input instanceof GramJs.ForumTopic ? input : undefined;
const { unreadReactionsCount } = dialog || monoForumDialog || forumTopic || {};
const { unreadMentionsCount } = dialog || forumTopic || {};
const { unreadMark } = dialog || monoForumDialog || {};
return omitUndefined<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 type { ApiTopic } from '../../types';
import type { ApiTopic, ApiTopicWithState } from '../../types';
import { buildThreadReadState } from './chats';
import { buildApiPeerNotifySettings, getApiChatIdFromMtpPeer } from './peers';
export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | undefined {
function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | undefined {
if (forumTopic instanceof GramJs.ForumTopicDeleted) {
return undefined;
}
@ -20,10 +21,6 @@ export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | und
title,
iconColor,
iconEmojiId,
topMessage,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
fromId,
notifySettings,
titleMissing,
@ -40,12 +37,24 @@ export function buildApiTopic(forumTopic: GramJs.TypeForumTopic): ApiTopic | und
title,
iconColor,
iconEmojiId: iconEmojiId?.toString(),
lastMessageId: topMessage,
unreadCount,
unreadMentionsCount,
unreadReactionsCount,
fromId: getApiChatIdFromMtpPeer(fromId),
notifySettings: buildApiPeerNotifySettings(notifySettings),
isTitleMissing: titleMissing,
};
}
export function buildApiTopicWithState(forumTopic: GramJs.TypeForumTopic): ApiTopicWithState | undefined {
if (forumTopic instanceof GramJs.ForumTopicDeleted) {
return undefined;
}
const topic = buildApiTopic(forumTopic);
if (!topic) return undefined;
const isMin = topic.isMin;
return {
topic,
readState: !isMin ? buildThreadReadState(forumTopic) : undefined,
lastMessageId: !isMin ? forumTopic.topMessage : undefined,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -77,10 +77,7 @@ import {
selectChatType,
selectCurrentMessageList,
selectCustomEmoji,
selectDraft,
selectEditingDraft,
selectEditingMessage,
selectEditingScheduledDraft,
selectIsChatWithSelf,
selectIsCurrentUserFrozen,
selectIsCurrentUserPremium,
@ -91,7 +88,6 @@ import {
selectNewestMessageWithBotKeyboardButtons,
selectNotifyDefaults,
selectNotifyException,
selectNoWebPage,
selectPeer,
selectPeerPaidMessagesStars,
selectPeerStory,
@ -107,6 +103,12 @@ import {
} from '../../global/selectors';
import { selectCurrentLimit } from '../../global/selectors/limits';
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 buildClassName from '../../util/buildClassName';
import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates/dateFormat';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,6 @@ import {
selectChatLastMessageId,
selectChatMessage,
selectCurrentMessageList,
selectDraft,
selectIsCurrentUserFrozen,
selectIsCurrentUserPremium,
selectIsForumPanelClosed,
@ -46,19 +45,19 @@ import {
selectPeerStory,
selectSender,
selectTabState,
selectThreadParam,
selectTopicFromMessage,
selectTopicsInfo,
selectUser,
selectUserStatus,
} from '../../../global/selectors';
import { selectDraft, selectThreadLocalStateParam } from '../../../global/selectors/threads';
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { isUserId } from '../../../util/entities/ids';
import { getChatFolderIds } from '../../../util/folderManager';
import { createLocationHash } from '../../../util/routing';
import useSelectorSignal from '../../../hooks/data/useSelectorSignal';
import { useSelectorSignal } from '../../../hooks/data/useSelector';
import useAppLayout from '../../../hooks/useAppLayout';
import useChatContextActions from '../../../hooks/useChatContextActions';
import useEnsureMessage from '../../../hooks/useEnsureMessage';
@ -96,11 +95,11 @@ type OwnProps = {
previewMessageId?: number;
className?: string;
withTags?: boolean;
isFoldersSidebarShown?: boolean;
observeIntersection?: ObserveFn;
onDragEnter?: (chatId: string) => void;
onDragLeave?: NoneToVoidFunction;
onReorderAnimationEnd?: NoneToVoidFunction;
isFoldersSidebarShown?: boolean;
};
type StateProps = {
@ -108,7 +107,6 @@ type StateProps = {
monoforumChannel?: ApiChat;
lastMessageStory?: ApiTypeStory;
listedTopicIds?: number[];
topics?: Record<number, ApiTopic>;
isMuted?: boolean;
user?: ApiUser;
userStatus?: ApiUserStatus;
@ -142,7 +140,6 @@ const Chat: FC<OwnProps & StateProps> = ({
animationType,
isPinned,
listedTopicIds,
topics,
observeIntersection,
chat,
monoforumChannel,
@ -170,16 +167,16 @@ const Chat: FC<OwnProps & StateProps> = ({
previewMessageId,
className,
isSynced,
onDragEnter,
onDragLeave,
isAccountFrozen,
chatFolderIds,
orderedFolderIds,
chatFoldersById,
areTagsEnabled,
withTags,
onReorderAnimationEnd,
isFoldersSidebarShown,
onDragEnter,
onDragLeave,
onReorderAnimationEnd,
}) => {
const {
openChat,
@ -245,7 +242,7 @@ const Chat: FC<OwnProps & StateProps> = ({
isSavedDialog,
isPreview,
onReorderAnimationEnd,
topics,
topicIds: listedTopicIds,
hasTags: shouldRenderTags,
});
@ -366,7 +363,7 @@ const Chat: FC<OwnProps & StateProps> = ({
isSavedDialog,
currentUserId,
isPreview,
topics,
topicIds: listedTopicIds,
});
const isIntersecting = useIsIntersecting(ref, chat ? observeIntersection : undefined);
@ -445,7 +442,6 @@ const Chat: FC<OwnProps & StateProps> = ({
isMuted={isMuted}
shouldShowOnlyMostImportant
forceHidden={getIsForumPanelClosed}
topics={topics}
isSelected={isSelected}
isOnAvatar
/>
@ -485,7 +481,6 @@ const Chat: FC<OwnProps & StateProps> = ({
isMuted={isMuted}
isSavedDialog={isSavedDialog}
hasMiniApp={user?.hasMainMiniApp}
topics={topics}
isSelected={isSelected}
transitionClassName="chat-badge-transition"
/>
@ -557,7 +552,7 @@ export default memo(withGlobal<OwnProps>(
const {
chatId: currentChatId,
threadId: currentThreadId,
type: messageListType,
type: currentMessageListType,
} = selectCurrentMessageList(global) || {};
const isSelected = !isPreview && chatId === currentChatId && (isSavedDialog
? chatId === currentThreadId : currentThreadId === MAIN_THREAD_ID);
@ -567,7 +562,7 @@ export default memo(withGlobal<OwnProps>(
const userStatus = selectUserStatus(global, chatId);
const lastMessageTopic = lastMessage && selectTopicFromMessage(global, lastMessage);
const typingStatus = selectThreadParam(global, chatId, MAIN_THREAD_ID, 'typingStatus');
const typingStatus = selectThreadLocalStateParam(global, chatId, MAIN_THREAD_ID, 'typingStatus');
const topicsInfo = selectTopicsInfo(global, chatId);
@ -585,9 +580,11 @@ export default memo(withGlobal<OwnProps>(
isSelected,
isSelectedForum,
isForumPanelOpen: selectIsForumPanelOpen(global),
canScrollDown: isSelected && messageListType === 'thread',
canScrollDown: isSelected && currentMessageListType === 'thread',
canChangeFolder: (global.chatFolders.orderedIds?.length || 0) > 1,
lastMessageOutgoingStatus: isOutgoing && lastMessage ? selectOutgoingStatus(global, lastMessage) : undefined,
lastMessageOutgoingStatus: isOutgoing && lastMessage && !isSavedDialog
? selectOutgoingStatus(global, chatId, MAIN_THREAD_ID, lastMessage.id, 'thread')
: undefined,
user,
userStatus,
lastMessageTopic,
@ -597,7 +594,6 @@ export default memo(withGlobal<OwnProps>(
lastMessageId,
currentUserId: global.currentUserId!,
listedTopicIds: topicsInfo?.listedTopicIds,
topics: topicsInfo?.topicsById,
isSynced: global.isSynced,
lastMessageStory,
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 type { ApiChat, ApiTopic } from '../../../api/types';
import type { GlobalState } from '../../../global/types';
import type { Signal } from '../../../util/signals';
import { type ApiChat, type ApiTopic, MAIN_THREAD_ID } from '../../../api/types';
import { selectTopicsInfo } from '../../../global/selectors';
import { selectThreadReadState } from '../../../global/selectors/threads';
import buildClassName from '../../../util/buildClassName';
import { buildCollectionByCallback } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';
import { isSignal } from '../../../util/signals';
import { formatIntegerCompact } from '../../../util/textFormat';
import { extractCurrentThemeParams } from '../../../util/themeStyle';
import useSelector, { useShallowSelector } from '../../../hooks/data/useSelector';
import useDerivedState from '../../../hooks/useDerivedState';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
@ -31,7 +36,6 @@ type OwnProps = {
shouldShowOnlyMostImportant?: boolean;
hasMiniApp?: boolean;
forceHidden?: boolean | Signal<boolean>;
topics?: Record<number, ApiTopic>;
isSelected?: boolean;
isOnAvatar?: boolean;
transitionClassName?: string;
@ -40,7 +44,6 @@ type OwnProps = {
const ChatBadge = ({
topic,
topics,
chat,
isPinned,
isMuted,
@ -58,23 +61,47 @@ const ChatBadge = ({
const lang = useLang();
const readStateSelector = useCallback((global: GlobalState) => {
return selectThreadReadState(global, chat.id, topic?.id || MAIN_THREAD_ID);
}, [chat.id, topic?.id]);
const readState = useSelector(readStateSelector);
const {
unreadMentionsCount = 0, unreadReactionsCount = 0,
} = !chat.isForum ? chat : {}; // TODO[forums] Unread mentions and reactions temporarily disabled for forums
unreadMentionsCount: stateUnreadMentionsCount = 0,
unreadReactionsCount: stateUnreadReactionsCount = 0,
unreadCount: stateUnreadCount = 0,
hasUnreadMark,
} = readState || {};
const topicsInfoSelector = useCallback((global: GlobalState) => {
return selectTopicsInfo(global, chat.id);
}, [chat.id]);
const topicsInfo = useShallowSelector(topicsInfoSelector);
const { listedTopicIds, topicsById } = topicsInfo || {};
const topicsReadStateSelector = useCallback((global: GlobalState) => {
return buildCollectionByCallback(listedTopicIds || [], (tId) => (
[tId, selectThreadReadState(global, chat.id, tId)]
));
}, [chat.id, listedTopicIds]);
const topicsReadStates = useShallowSelector(topicsReadStateSelector);
const isTopicUnopened = !isPinned && topic && !wasTopicOpened;
const isForum = chat.isForum && !topic;
const topicsWithUnread = useMemo(() => (
isForum && topics ? Object.values(topics).filter(({ unreadCount }) => unreadCount) : undefined
), [topics, isForum]);
const topicsWithUnreadIds = useMemo(() => (
isForum && listedTopicIds ? listedTopicIds.filter((tId) => topicsReadStates[tId]?.unreadCount) : undefined
), [listedTopicIds, isForum, topicsReadStates]);
const topicsWithUnreadMentionsIds = useMemo(() => (
isForum && listedTopicIds ? listedTopicIds.filter((tId) => topicsReadStates[tId]?.unreadMentionsCount) : undefined
), [listedTopicIds, isForum, topicsReadStates]);
const topicsWithUnreadReactionsIds = useMemo(() => (
isForum && listedTopicIds ? listedTopicIds.filter((tId) => topicsReadStates[tId]?.unreadReactionsCount) : undefined
), [listedTopicIds, isForum, topicsReadStates]);
const unreadCount = useMemo(() => {
if (!isForum) {
return (topic || chat).unreadCount;
}
return topicsWithUnread?.length;
}, [chat, topic, topicsWithUnread, isForum]);
const unreadCount = isForum ? topicsWithUnreadIds?.length : stateUnreadCount;
const unreadMentionsCount = isForum ? topicsWithUnreadMentionsIds?.length : stateUnreadMentionsCount;
const unreadReactionsCount = isForum ? topicsWithUnreadReactionsIds?.length : stateUnreadReactionsCount;
const shouldBeUnMuted = useMemo(() => {
if (!isForum) {
@ -82,17 +109,17 @@ const ChatBadge = ({
}
if (isMuted) {
return topicsWithUnread?.some((acc) => acc.notifySettings.mutedUntil === 0);
return topicsWithUnreadIds?.some((tId) => topicsById?.[tId]?.notifySettings.mutedUntil === 0);
}
const isEveryUnreadMuted = topicsWithUnread?.every((acc) => (
acc.notifySettings.mutedUntil && acc.notifySettings.mutedUntil > getServerTime()
));
const isEveryUnreadMuted = topicsWithUnreadIds?.every((tId) => {
const mutedUntil = topicsById?.[tId]?.notifySettings.mutedUntil;
return mutedUntil && mutedUntil > getServerTime();
});
return !isEveryUnreadMuted;
}, [isForum, isMuted, topicsWithUnread, topic?.notifySettings.mutedUntil]);
}, [isForum, isMuted, topicsWithUnreadIds, topicsById, topic?.notifySettings.mutedUntil]);
const hasUnreadMark = topic ? false : chat.hasUnreadMark;
const isUnread = Boolean((unreadCount || hasUnreadMark) && !isSavedDialog);
const resolvedForceHidden = useDerivedState(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,8 @@ import {
getWebPagePhoto,
getWebPageVideo,
} from '../../../global/helpers';
import { selectNoWebPage, selectTabState, selectWebPage } from '../../../global/selectors';
import { selectTabState, selectWebPage } from '../../../global/selectors';
import { selectNoWebPage } from '../../../global/selectors/threads';
import buildClassName from '../../../util/buildClassName';
import useThumbnail from '../../../hooks/media/useThumbnail';

View File

@ -34,6 +34,8 @@ export default function useMessageObservers(
} = useIntersectionObserver({
rootRef: containerRef,
throttleMs: INTERSECTION_THROTTLE_FOR_READING,
// `memoFirstUnreadIdRef` is set after the first render, firing callback before that can skip some entries, like the last message
shouldSkipFirst: true,
}, (entries) => {
if (type !== 'thread' || isBackgroundModeActive()) {
return;
@ -97,7 +99,7 @@ export default function useMessageObservers(
}
if (reactionIds.length) {
animateUnreadReaction({ messageIds: reactionIds });
animateUnreadReaction({ chatId, messageIds: reactionIds });
}
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,8 @@ import type { ThreadId } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { getIsSavedDialog } from '../../../global/helpers';
import { selectChat, selectThreadParam, selectTopic } from '../../../global/selectors';
import { selectChat } from '../../../global/selectors';
import { selectThreadLocalStateParam, selectThreadReadState } from '../../../global/selectors/threads';
import { isUserId } from '../../../util/entities/ids';
import useConnectionStatus from '../../../hooks/useConnectionStatus';
@ -75,7 +76,7 @@ const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
round
color="translucent"
size="smaller"
ariaLabel={lang('ChatListContextMaskAsRead')}
ariaLabel={lang('ChatListContextMarkAsRead')}
onClick={handleMarkAsRead}
className={styles.markAsReadButton}
iconName="readchats"
@ -134,11 +135,10 @@ const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId }): Complete<StateProps> => {
const chat = selectChat(global, chatId);
const typingStatus = selectThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'typingStatus');
const typingStatus = selectThreadLocalStateParam(global, chatId, threadId || MAIN_THREAD_ID, 'typingStatus');
const isSavedDialog = getIsSavedDialog(chatId, threadId || MAIN_THREAD_ID, global.currentUserId);
const unreadCount = chat?.isForum && threadId
? selectTopic(global, chatId, threadId)?.unreadCount
: chat?.unreadCount;
const readState = selectThreadReadState(global, chatId, threadId || MAIN_THREAD_ID);
const unreadCount = readState?.unreadCount;
return {
chat,
@ -148,7 +148,7 @@ export default memo(withGlobal<OwnProps>(
typingStatus,
isSavedDialog,
unreadCount,
hasUnreadMark: chat?.hasUnreadMark,
hasUnreadMark: readState?.hasUnreadMark,
};
},
)(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 type {
@ -42,12 +42,6 @@ type OwnProps = {
const GIFT_STICKER_SIZE = 36;
function selectOptionalPeer(peerId?: string) {
return (global: GlobalState) => (
peerId ? selectPeer(global, peerId) : undefined
);
}
const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
const { openStarsTransactionModal } = getActions();
const {
@ -62,7 +56,11 @@ const StarsTransactionItem = ({ transaction, className }: OwnProps) => {
const oldLang = useOldLang();
const peerId = transactionPeer.type === 'peer' ? transactionPeer.id : undefined;
const peer = useSelector(selectOptionalPeer(peerId));
const peerSelector = useCallback((global: GlobalState) => {
return peerId ? selectPeer(global, peerId) : undefined;
}, [peerId]);
const peer = useSelector(peerSelector);
const starGift = transaction.starGift;
const isUniqueGift = starGift?.type === 'starGiftUnique';
const giftSticker = starGift && getStickerFromGift(starGift);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -63,6 +63,7 @@ import { callApi, cancelApiProgress } from '../../../api/gramjs';
import {
getIsSavedDialog,
getUserFullName,
groupMessageIdsByThreadId,
isChatChannel,
isChatSuperGroup,
isDeletedUser,
@ -73,7 +74,7 @@ import {
} from '../../helpers';
import { isApiPeerChat, isApiPeerUser } from '../../helpers/peers';
import {
addActionHandler, getActions, getGlobal, setGlobal,
addActionHandler, getActions, getGlobal, getPromiseActions, setGlobal,
} from '../../index';
import {
addChatMessagesById,
@ -84,7 +85,6 @@ import {
removeRequestedMessageTranslation,
removeUnreadMentions,
replaceSettings,
replaceThreadParam,
replaceUserStatuses,
safeReplacePinnedIds,
safeReplaceViewportIds,
@ -102,13 +102,18 @@ import {
updateRequestedMessageTranslation,
updateScheduledMessages,
updateSponsoredMessage,
updateThreadInfo,
updateThreadUnreadFromForwardedMessage,
updateTopic,
updateTopicWithState,
updateUploadByMessageKey,
updateUserFullInfo,
} from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
import {
replaceThreadLocalStateParam,
replaceThreadReadStateParam,
updateThreadInfo,
updateThreadInfoMessagesCount,
updateThreadReadState,
} from '../../reducers/threads';
import {
selectCanForwardMessage,
selectChat,
@ -119,10 +124,7 @@ import {
selectCurrentMessageList,
selectCurrentViewedStory,
selectCustomEmoji,
selectDraft,
selectEditingId,
selectEditingMessage,
selectEditingScheduledId,
selectFirstMessageId,
selectFirstUnreadId,
selectFocusedMessageId,
@ -137,7 +139,6 @@ import {
selectLanguageCode,
selectListedIds,
selectMessageReplyInfo,
selectNoWebPage,
selectOutlyingListByMessageId,
selectPeer,
selectPeerStory,
@ -145,20 +146,26 @@ import {
selectPollFromMessage,
selectRealLastReadId,
selectReplyCanBeSentToChat,
selectSavedDialogIdFromMessage,
selectScheduledMessage,
selectSendAs,
selectTabState,
selectThreadIdFromMessage,
selectThreadInfo,
selectThreadParam,
selectTopic,
selectTranslationLanguage,
selectUser,
selectUserFullInfo,
selectUserStatus,
selectViewportIds,
} from '../../selectors';
import {
selectDraft,
selectEditingId,
selectEditingScheduledId,
selectNoWebPage,
selectSavedDialogIdFromMessage,
selectThreadIdFromMessage,
selectThreadInfo,
selectThreadLocalStateParam,
selectThreadReadState,
} from '../../selectors/threads';
import { updateWithLocalMedia } from '../apiUpdaters/messages';
import { deleteMessages } from '../apiUpdaters/messages';
@ -299,7 +306,7 @@ async function loadWithBudget<T extends GlobalState>(
addActionHandler('loadMessage', async (global, actions, payload): Promise<void> => {
const {
chatId, messageId, replyOriginForId, threadUpdate,
chatId, messageId, replyOriginForId,
} = payload;
const chat = selectChat(global, chatId);
@ -307,20 +314,28 @@ addActionHandler('loadMessage', async (global, actions, payload): Promise<void>
return;
}
const message = await loadMessage(global, chat, messageId, replyOriginForId);
if (message && threadUpdate) {
const { lastMessageId, isDeleting } = threadUpdate;
global = getGlobal();
global = updateThreadUnreadFromForwardedMessage(
global,
message,
chatId,
lastMessageId,
isDeleting,
);
setGlobal(global);
const result = await callApi('fetchMessage', { chat, messageId });
if (!result) {
return undefined;
}
if (result === MESSAGE_DELETED) {
if (replyOriginForId) {
global = getGlobal();
const replyMessage = selectChatMessage(global, chat.id, replyOriginForId);
global = updateChatMessage(global, chat.id, replyOriginForId, {
...replyMessage,
replyInfo: undefined,
});
setGlobal(global);
}
return undefined;
}
global = getGlobal();
global = updateChatMessage(global, chat.id, messageId, result.message);
setGlobal(global);
});
addActionHandler('loadMessagesById', async (global, actions, payload): Promise<void> => {
@ -953,14 +968,11 @@ async function saveDraft<T extends GlobalState>({
const newDraft: ApiDraft | undefined = draft ? {
...draft,
replyInfo,
date: Math.floor(Date.now() / 1000),
date: (noLocalTimeUpdate && draft.date) || getServerTime(),
isLocal: true,
} : undefined;
global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft);
if (!noLocalTimeUpdate) {
global = updateChat(global, chatId, { draftDate: newDraft?.date });
}
global = replaceThreadLocalStateParam(global, chatId, threadId, 'draft', newDraft);
setGlobal(global);
@ -976,8 +988,7 @@ async function saveDraft<T extends GlobalState>({
}
global = getGlobal();
global = replaceThreadParam(global, chatId, threadId, 'draft', newDraft);
global = updateChat(global, chatId, { draftDate: newDraft?.date });
global = replaceThreadLocalStateParam(global, chatId, threadId, 'draft', newDraft);
setGlobal(global);
}
@ -985,7 +996,7 @@ async function saveDraft<T extends GlobalState>({
addActionHandler('toggleMessageWebPage', (global, actions, payload): ActionReturnType => {
const { chatId, threadId, noWebPage } = payload;
return replaceThreadParam(global, chatId, threadId, 'noWebPage', noWebPage);
return replaceThreadLocalStateParam(global, chatId, threadId, 'noWebPage', noWebPage);
});
addActionHandler('pinMessage', (global, actions, payload): ActionReturnType => {
@ -1017,7 +1028,7 @@ addActionHandler('unpinAllMessages', async (global, actions, payload): Promise<v
pinnedIds?.forEach((id) => {
global = updateChatMessage(global, chatId, id, { isPinned: false });
});
global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'pinnedIds', []);
global = replaceThreadLocalStateParam(global, chat.id, threadId, 'pinnedIds', []);
setGlobal(global);
});
@ -1287,16 +1298,9 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn
const viewportIds = selectViewportIds(global, chatId, threadId, tabId);
const minId = selectFirstUnreadId(global, chatId, threadId);
const topic = selectTopic(global, chatId, threadId);
const threadReadState = selectThreadReadState(global, chatId, threadId);
if (threadId !== MAIN_THREAD_ID && !chat.isForum) {
global = updateThreadInfo(global, chatId, threadId, {
lastReadInboxMessageId: maxId,
});
return global;
}
if (!viewportIds || !minId || (!chat.unreadCount && !topic?.unreadCount)) {
if (!viewportIds || !minId || !threadReadState?.unreadCount) {
return global;
}
@ -1305,42 +1309,21 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn
return global;
}
if (chat.isForum && topic) {
global = updateThreadInfo(global, chatId, threadId, {
lastReadInboxMessageId: maxId,
});
const newTopicUnreadCount = Math.max(0, topic.unreadCount - readCount);
if (newTopicUnreadCount === 0 && !chat.isBotForum && chat.unreadCount) {
global = updateChat(global, chatId, {
unreadCount: Math.max(0, chat.unreadCount - 1),
});
}
const newUnreadCount = Math.max(0, (threadReadState.unreadCount || 0) - readCount);
global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadCount', newUnreadCount);
global = replaceThreadReadStateParam(global, chatId, threadId, 'lastReadInboxMessageId', maxId);
return updateTopic(global, chatId, Number(threadId), {
unreadCount: newTopicUnreadCount,
});
}
return updateChat(global, chatId, {
lastReadInboxMessageId: maxId,
unreadCount: Math.max(0, (chat.unreadCount || 0) - readCount),
});
return global;
});
addActionHandler('markMessagesRead', (global, actions, payload): ActionReturnType => {
const { messageIds, tabId = getCurrentTabId(), shouldFetchUnreadReactions } = payload;
const chat = selectCurrentChat(global, tabId);
const { chatId, messageIds } = payload;
const chat = selectChat(global, chatId);
if (!chat) {
return;
}
void callApi('markMessagesRead', { chat, messageIds })
.then(() => {
if (shouldFetchUnreadReactions) {
actions.fetchUnreadReactions({ chatId: chat.id });
}
});
void callApi('markMessagesRead', { chat, messageIds });
});
addActionHandler('loadWebPagePreview', async (global, actions, payload): Promise<void> => {
@ -1530,24 +1513,17 @@ addActionHandler('loadScheduledHistory', async (global, actions, payload): Promi
global = getGlobal();
global = updateScheduledMessages(global, chat.id, byId);
global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'scheduledIds', ids);
const idsByThreadId = groupMessageIdsByThreadId(global, chat.id, ids, true);
if (!ids.length) {
global = updatePeerFullInfo(global, chat.id, { hasScheduledMessages: false });
}
if (chat?.isForum) {
const scheduledPerThread: Record<ThreadId, number[]> = {};
messages.forEach((message) => {
const threadId = selectThreadIdFromMessage(global, message);
const scheduledInThread = scheduledPerThread[threadId] || [];
scheduledInThread.push(message.id);
scheduledPerThread[threadId] = scheduledInThread;
});
Object.entries(scheduledPerThread).forEach(([threadId, scheduledIds]) => {
global = replaceThreadParam(global, chat.id, Number(threadId), 'scheduledIds', scheduledIds);
});
}
Object.entries(idsByThreadId).forEach(([tId, newThreadScheduledIds]) => {
const threadId = tId as ThreadId;
if (!chat.isForum && threadId !== MAIN_THREAD_ID) return;
global = replaceThreadLocalStateParam(global, chat.id, threadId, 'scheduledIds', newThreadScheduledIds);
});
setGlobal(global);
});
@ -1798,12 +1774,12 @@ async function loadViewportMessages<T extends GlobalState>(
}
const {
messages, count,
messages, count, topics,
} = result;
global = getGlobal();
const localTypingDrafts = selectThreadParam(global, chatId, threadId, 'typingDraftIdByRandomId');
const localTypingDrafts = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId');
const typingDraftMessages = localTypingDrafts ? Object.values(localTypingDrafts)
.map((id) => selectChatMessage(global, chatId, id))
.filter(Boolean) : [];
@ -1846,45 +1822,18 @@ async function loadViewportMessages<T extends GlobalState>(
}
}
if (count) {
global = updateThreadInfo(global, chat.id, threadId, {
messagesCount: count,
});
if (count !== undefined) {
global = updateThreadInfoMessagesCount(global, chat.id, threadId, count);
}
topics.forEach((topicState) => {
global = updateTopicWithState(global, chat.id, topicState);
});
setGlobal(global);
onLoaded?.();
}
async function loadMessage<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) {
if (offsetId < sourceIds[0]) {
return 0;
@ -2297,9 +2246,38 @@ addActionHandler('hideSponsored', async (global, actions, payload): Promise<void
});
});
addActionHandler('fetchUnreadMentions', async (global, actions, payload): Promise<void> => {
const { chatId, offsetId } = payload;
await fetchUnreadMentions(global, chatId, offsetId);
addActionHandler('loadUnreadMentions', async (global, actions, payload): Promise<void> => {
const { chatId, threadId = MAIN_THREAD_ID, offsetId } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
const result = await callApi('fetchUnreadMentions', {
chat,
threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined,
offsetId,
});
if (!result) return;
const { messages, topics, totalCount } = result;
const byId = buildCollectionByKey(messages, 'id');
const ids = Object.keys(byId).map(Number);
global = getGlobal();
global = addChatMessagesById(global, chat.id, byId);
topics.forEach((topicState) => {
global = updateTopicWithState(global, chat.id, topicState);
});
global = addUnreadMentions({
global,
chatId,
ids,
totalCount,
});
setGlobal(global);
});
addActionHandler('approveSuggestedPost', async (global, actions, payload): Promise<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 => {
const { chatId, messageIds, tabId = getCurrentTabId() } = payload;
const { chatId, messageIds } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
global = removeUnreadMentions(global, chatId, chat, messageIds, true);
global = removeUnreadMentions({
global,
chatId,
ids: messageIds,
});
setGlobal(global);
actions.markMessagesRead({ messageIds, tabId });
actions.markMessagesRead({ chatId, messageIds });
});
addActionHandler('focusNextMention', async (global, actions, payload): Promise<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();
const previousChatId = chat.id;
chat = selectCurrentChat(global, tabId);
if (!chat?.unreadMentions || previousChatId !== chat.id) return;
readState = selectThreadReadState(global, chatId, threadId);
if (!readState?.unreadMentions) return;
}
actions.focusMessage({ chatId: chat.id, messageId: chat.unreadMentions[0], tabId });
actions.focusMessage({ chatId, messageId: readState.unreadMentions[0], tabId });
});
addActionHandler('readAllMentions', (global, actions, payload): ActionReturnType => {
@ -2428,17 +2388,13 @@ addActionHandler('readAllMentions', (global, actions, payload): ActionReturnType
const chat = selectChat(global, chatId);
if (!chat) return undefined;
callApi('readAllMentions', { chat, threadId: threadId === MAIN_THREAD_ID ? undefined : threadId });
callApi('readAllMentions', { chat, threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined });
if (threadId === MAIN_THREAD_ID) {
return updateChat(global, chat.id, {
unreadMentionsCount: undefined,
unreadMentions: undefined,
});
}
// TODO[Forums]: Support mentions in threads
return undefined;
global = updateThreadReadState(global, chatId, threadId, {
unreadMentionsCount: 0,
unreadMentions: undefined,
});
return global;
});
addActionHandler('openUrl', (global, actions, payload): ActionReturnType => {
@ -2805,7 +2761,7 @@ addActionHandler('loadMessageViews', async (global, actions, payload): Promise<v
}, true);
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 { getCurrentTabId } from '../../../util/establishMultitabRole';
import {
buildCollectionByCallback, buildCollectionByKey, omit, partition, unique,
buildCollectionByCallback, buildCollectionByKey, omit, partition,
} from '../../../util/iteratees';
import { getMessageKey } from '../../../util/keys/messageKey';
import * as mediaLoader from '../../../util/mediaLoader';
@ -19,16 +19,23 @@ import {
isMessageLocal,
isSameReaction,
} from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { addActionHandler, getGlobal, getPromiseActions, setGlobal } from '../../index';
import {
addChatMessagesById, updateChat, updateChatMessage,
addChatMessagesById,
updateChatMessage,
updateTopicWithState,
} from '../../reducers';
import { addMessageReaction, subtractXForEmojiInteraction, updateUnreadReactions } from '../../reducers/reactions';
import {
addMessageReaction,
addUnreadReactions,
removeUnreadReactions,
subtractXForEmojiInteraction,
} from '../../reducers/reactions';
import { updateTabState } from '../../reducers/tabs';
import { updateThreadReadState } from '../../reducers/threads';
import {
selectChat,
selectChatMessage,
selectCurrentChat,
selectDefaultReaction,
selectIsChatWithSelf,
selectIsCurrentUserFrozen,
@ -37,6 +44,7 @@ import {
selectPerformanceSettingsValue,
selectTabState,
} from '../../selectors';
import { selectThreadReadState } from '../../selectors/threads';
const INTERACTION_RANDOM_OFFSET = 40;
@ -467,98 +475,82 @@ addActionHandler('sendWatchingEmojiInteraction', (global, actions, payload): Act
}, tabId);
});
addActionHandler('fetchUnreadReactions', async (global, actions, payload): Promise<void> => {
const { chatId, offsetId } = payload;
addActionHandler('loadUnreadReactions', async (global, actions, payload): Promise<void> => {
const { chatId, threadId = MAIN_THREAD_ID, offsetId } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
const result = await callApi('fetchUnreadReactions', { chat, offsetId, addOffset: offsetId ? -1 : undefined });
const result = await callApi('fetchUnreadReactions', {
chat,
threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined,
offsetId,
});
// Server side bug, when server returns unread reactions count > 0 for deleted messages
if (!result || !result.messages.length) {
global = getGlobal();
global = updateUnreadReactions(global, chatId, {
unreadReactionsCount: 0,
});
if (!result) return;
setGlobal(global);
return;
}
const { messages } = result;
const { messages, topics, totalCount } = result;
const byId = buildCollectionByKey(messages, 'id');
const ids = Object.keys(byId).map(Number);
global = getGlobal();
global = addChatMessagesById(global, chat.id, byId);
global = updateUnreadReactions(global, chatId, {
unreadReactions: unique([...(chat.unreadReactions || []), ...ids]).sort((a, b) => b - a),
topics.forEach((topicState) => {
global = updateTopicWithState(global, chat.id, topicState);
});
global = addUnreadReactions({
global,
chatId,
ids,
totalCount,
});
setGlobal(global);
});
addActionHandler('animateUnreadReaction', (global, actions, payload): ActionReturnType => {
const { messageIds, tabId = getCurrentTabId() } = payload;
const { chatId, messageIds, tabId = getCurrentTabId() } = payload;
const chat = selectCurrentChat(global, tabId);
if (!chat) return undefined;
if (!chat.unreadReactionsCount) {
return updateUnreadReactions(global, chat.id, {
unreadReactions: [],
});
}
const unreadReactionsCount = Math.max(chat.unreadReactionsCount - messageIds.length, 0);
const unreadReactions = (chat.unreadReactions || []).filter((id) => !messageIds.includes(id));
global = updateUnreadReactions(global, chat.id, {
unreadReactions,
unreadReactionsCount,
});
global = removeUnreadReactions({ global, chatId, ids: messageIds });
setGlobal(global);
actions.markMessagesRead({ messageIds, shouldFetchUnreadReactions: true, tabId });
actions.markMessagesRead({ chatId, messageIds });
if (!selectPerformanceSettingsValue(global, 'reactionEffects')) return undefined;
global = getGlobal();
messageIds.forEach((id) => {
const message = selectChatMessage(global, chat.id, id);
const message = selectChatMessage(global, chatId, id);
if (!message) return;
const { reaction, isOwn, isUnread } = message.reactions?.recentReactions?.[0] ?? {};
if (reaction && isUnread && !isOwn) {
const messageKey = getMessageKey(message);
actions.startActiveReaction({ containerId: messageKey, reaction, tabId: getCurrentTabId() });
actions.startActiveReaction({ containerId: messageKey, reaction, tabId });
}
});
return undefined;
});
addActionHandler('focusNextReaction', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const chat = selectCurrentChat(global, tabId);
addActionHandler('focusNextReaction', async (global, actions, payload): Promise<void> => {
const { chatId, threadId = MAIN_THREAD_ID, tabId = getCurrentTabId() } = payload || {};
let readState = selectThreadReadState(global, chatId, threadId);
if (!chat?.unreadReactions) {
if (chat?.unreadReactionsCount) {
return updateChat(global, chat.id, {
unreadReactionsCount: 0,
});
}
return undefined;
if (!readState?.unreadReactions?.length) {
await getPromiseActions().loadUnreadReactions({ chatId, threadId });
global = getGlobal();
readState = selectThreadReadState(global, chatId, threadId);
if (!readState?.unreadReactions?.length) return;
}
actions.focusMessage({
chatId: chat.id, messageId: chat.unreadReactions[0], tabId, scrollTargetPosition: 'end',
chatId, threadId, messageId: readState.unreadReactions[0], tabId, scrollTargetPosition: 'end',
});
actions.markMessagesRead({ messageIds: [chat.unreadReactions[0]], tabId });
return undefined;
actions.markMessagesRead({ chatId, messageIds: [readState.unreadReactions[0]] });
});
addActionHandler('readAllReactions', (global, actions, payload): ActionReturnType => {
@ -566,17 +558,13 @@ addActionHandler('readAllReactions', (global, actions, payload): ActionReturnTyp
const chat = selectChat(global, chatId);
if (!chat) return undefined;
callApi('readAllReactions', { chat, threadId: threadId === MAIN_THREAD_ID ? undefined : threadId });
callApi('readAllReactions', { chat, threadId: threadId !== MAIN_THREAD_ID ? threadId : undefined });
if (threadId === MAIN_THREAD_ID) {
return updateUnreadReactions(global, chat.id, {
unreadReactionsCount: undefined,
unreadReactions: undefined,
});
}
// TODO[Forums]: Support unread reactions in threads
return undefined;
global = updateThreadReadState(global, chatId, threadId, {
unreadReactionsCount: 0,
unreadReactions: undefined,
});
return global;
});
addActionHandler('loadTopReactions', async (global): Promise<void> => {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,7 +35,13 @@ import { areSortedArraysIntersecting, unique } from '../../util/iteratees';
import { isLocalMessageId } from '../../util/keys/messageKey';
import { getServerTime } from '../../util/serverTime';
import { getGlobal } from '../index';
import { selectPollFromMessage, selectWebPageFromMessage } from '../selectors';
import {
selectChatMessage,
selectPollFromMessage,
selectScheduledMessage,
selectWebPageFromMessage,
} from '../selectors';
import { selectThreadIdFromMessage } from '../selectors/threads';
import { getMainUsername } from './users';
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
@ -546,3 +552,24 @@ export function createApiMessageFromTypingDraft({
editDate: getServerTime(),
};
}
export function groupMessageIdsByThreadId(
global: GlobalState, chatId: string, messageIds: number[], isScheduled: boolean, notShared?: boolean,
): Record<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 { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../config';
import arePropsShallowEqual from '../../util/arePropsShallowEqual';
import { areRecordsShallowEqual } from '../../util/areShallowEqual';
import { convertTonFromNanos } from '../../util/formatCurrency';
import { selectChat, selectPeer, selectUser } from '../selectors';
@ -494,5 +494,5 @@ export function getPrizeStarsTransactionFromGiveaway(message: ApiMessage): ApiSt
}
export function areInputSavedGiftsEqual(one: ApiInputSavedStarGift, two: ApiInputSavedStarGift) {
return arePropsShallowEqual(one, two);
return areRecordsShallowEqual(one, two);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,18 @@
import type { ApiChat, ApiMessage, ApiReactionWithPaid } from '../../api/types';
import type { GlobalState } from '../types';
import { type ApiMessage, type ApiReactionWithPaid } from '../../api/types';
import { MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN, MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config';
import { unique } from '../../util/iteratees';
import windowSize from '../../util/windowSize';
import {
MIN_LEFT_COLUMN_WIDTH,
SIDE_COLUMN_MAX_WIDTH,
} from '../../components/middle/helpers/calculateMiddleFooterTransforms';
import { updateReactionCount } from '../helpers';
import { groupMessageIdsByThreadId, updateReactionCount } from '../helpers';
import { selectIsChatWithSelf, selectSendAs, selectTabState } from '../selectors';
import { updateChat } from './chats';
import { selectThreadReadState } from '../selectors/threads';
import { updateChatMessage } from './messages';
import { replaceThreadReadStateParam } from './threads';
import { getIsMobile } from '../../hooks/useAppLayout';
@ -75,8 +77,60 @@ export function addMessageReaction<T extends GlobalState>(
});
}
export function updateUnreadReactions<T extends GlobalState>(
global: T, chatId: string, update: Pick<ApiChat, 'unreadReactionsCount' | 'unreadReactions'>,
): T {
return updateChat(global, chatId, update, true);
export function addUnreadReactions<T extends GlobalState>({
global, chatId, ids, totalCount,
}: {
global: T;
chatId: string;
ids: number[];
totalCount?: number;
}): T {
const messageIdsByThreadId = groupMessageIdsByThreadId(global, chatId, ids, false);
for (const threadId in messageIdsByThreadId) {
const messageIds = messageIdsByThreadId[threadId];
if (totalCount !== undefined) { // Assume that when `totalCount` is passed, server returned full id list
global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactions', messageIds);
global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactionsCount', totalCount);
continue;
}
const readState = selectThreadReadState(global, chatId, threadId);
const prevChatUnreadReactions = readState?.unreadReactions || [];
const updatedUnreadReactions = unique([...prevChatUnreadReactions, ...messageIds]).sort((a, b) => b - a);
global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactions', updatedUnreadReactions);
const delta = updatedUnreadReactions.length - prevChatUnreadReactions.length;
if (delta > 0) {
const unreadReactionsCount = (readState?.unreadReactionsCount || 0) + delta;
global = replaceThreadReadStateParam(global, chatId, threadId, 'unreadReactionsCount', unreadReactionsCount);
}
}
return global;
}
export function removeUnreadReactions<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 { GlobalState } from '../types';
import { buildCollectionByKey, omit, unique } from '../../util/iteratees';
import { omit, pick, unique } from '../../util/iteratees';
import {
selectChat, selectTopic, selectTopics,
selectTopicsInfo,
} from '../selectors';
import { updateThread, updateThreadInfo } from './messages';
import {
updateThreadInfo,
updateThreadLocalState,
updateThreadReadState,
} from './threads';
function updateTopicsStore<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>,
) {
const info = global.chats.topicsInfoById[chatId] || {};
@ -35,7 +50,7 @@ export function updateListedTopicIds<T extends GlobalState>(
global: T, chatId: string, topicIds: number[],
): T {
const listedIds = selectTopicsInfo(global, chatId)?.listedTopicIds || [];
return updateTopicsStore(global, chatId, {
return updateTopicsInfo(global, chatId, {
listedTopicIds: unique([
...listedIds,
...topicIds,
@ -43,35 +58,6 @@ export function updateListedTopicIds<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>(
global: T, chatId: string, topicId: number, update: Partial<ApiTopic>,
): T {
@ -82,29 +68,46 @@ export function updateTopic<T extends GlobalState>(
const topic = selectTopic(global, chatId, topicId);
const oldTopics = selectTopics(global, chatId);
const safeUpdate = update.isMin ? pick(update, SAFE_MIN_PROPERTIES) : update;
const updatedTopic = {
...topic,
...update,
...safeUpdate,
} as ApiTopic;
if (!updatedTopic.id) return global;
global = updateTopicsStore(global, chatId, {
global = updateTopicsInfo(global, chatId, {
topicsById: {
...oldTopics,
[topicId]: updatedTopic,
},
});
global = updateThread(global, chatId, updatedTopic.id, {
global = updateThreadLocalState(global, chatId, updatedTopic.id, {
firstMessageId: updatedTopic.id,
});
global = updateThreadInfo(global, chatId, updatedTopic.id, {
lastMessageId: updatedTopic.lastMessageId,
threadId: updatedTopic.id,
chatId,
});
return global;
}
export function updateTopicWithState<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;
}
@ -115,25 +118,17 @@ export function deleteTopic<T extends GlobalState>(
const topics = selectTopics(global, chatId);
if (!topics) return global;
global = updateTopicsStore(global, chatId, {
global = updateTopicsInfo(global, chatId, {
topicsById: omit(topics, [topicId]),
});
return global;
}
export function updateTopicLastMessageId<T extends GlobalState>(
global: T, chatId: string, threadId: number, lastMessageId: number,
) {
return updateTopic(global, chatId, threadId, {
lastMessageId,
});
}
export function replacePinnedTopicIds<T extends GlobalState>(
global: T, chatId: string, pinnedTopicIds: number[],
) {
return updateTopicsStore(global, chatId, {
return updateTopicsInfo(global, chatId, {
orderedPinnedTopicIds: pinnedTopicIds,
});
}

View File

@ -12,9 +12,7 @@ import type {
import type {
ChatTranslatedMessages,
MessageListType,
TabThread,
TextSummary,
Thread,
ThreadId,
} from '../../types';
import type { IAllowedAttachmentOptions } from '../helpers';
@ -24,7 +22,8 @@ import type {
import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types';
import {
ANONYMOUS_USER_ID, GENERAL_TOPIC_ID, SERVICE_NOTIFICATIONS_USER_ID,
GENERAL_TOPIC_ID,
SERVICE_NOTIFICATIONS_USER_ID,
WEB_APP_PLATFORM,
} from '../../config';
import { IS_TRANSLATION_SUPPORTED } from '../../util/browser/windowEnvironment';
@ -86,7 +85,17 @@ import { selectPeer, selectPeerPaidMessagesStars } from './peers';
import { selectPeerStory } from './stories';
import { selectCustomEmoji, selectIsStickerFavorite } from './symbols';
import { selectTabState } from './tabs';
import { selectTopic } from './topics';
import {
selectEditingId,
selectEditingScheduledId,
selectTabThreadParam,
selectThreadByMessage,
selectThreadInfo,
selectThreadLocalState,
selectThreadLocalStateParam,
selectThreadReadState,
} from './threads';
import { selectTopic, selectTopicFromMessage } from './topics';
import {
selectBot, selectUser, selectUserStatus,
} from './users';
@ -121,45 +130,8 @@ export function selectChatScheduledMessages<T extends GlobalState>(global: T, ch
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) {
return selectThreadParam(global, chatId, threadId, 'listedIds');
return selectThreadLocalStateParam(global, chatId, threadId, 'listedIds');
}
export function selectOutlyingListByMessageId<T extends GlobalState>(
@ -178,7 +150,7 @@ export function selectOutlyingListByMessageId<T extends GlobalState>(
export function selectOutlyingLists<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId,
) {
return selectThreadParam(global, chatId, threadId, 'outlyingLists');
return selectThreadLocalStateParam(global, chatId, threadId, 'outlyingLists');
}
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) {
return selectThreadParam(global, chatId, threadId, 'pinnedIds');
return selectThreadLocalStateParam(global, chatId, threadId, 'pinnedIds');
}
export function selectScheduledIds<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(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');
return selectThreadLocalStateParam(global, chatId, threadId, 'scheduledIds');
}
export function selectFirstMessageId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
return selectThreadParam(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];
return selectThreadLocalStateParam(global, chatId, threadId, 'firstMessageId');
}
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;
}
export function selectIsMessageUnread<T extends GlobalState>(global: T, message: ApiMessage) {
const { lastReadOutboxMessageId } = selectChat(global, message.chatId) || {};
return isMessageLocal(message) || !lastReadOutboxMessageId || lastReadOutboxMessageId < message.id;
export function selectIsMessageUnread<T extends GlobalState>(
global: T, chatId: string, threadId: ThreadId, messageId: number, messageListType: MessageListType,
) {
const { lastReadOutboxMessageId } = selectThreadReadState(global, chatId, threadId) || {};
const message = selectChatMessage(global, chatId, messageId);
if (!message) {
return false;
}
return messageListType === 'scheduled' || isMessageLocal(message)
|| !lastReadOutboxMessageId || lastReadOutboxMessageId < message.id;
}
export function selectOutgoingStatus<T extends GlobalState>(
global: T, message: ApiMessage, isScheduledList = false,
global: T, chatId: string, threadId: ThreadId, messageId: number, messageListType: MessageListType,
): ApiMessageOutgoingStatus {
if (!selectIsMessageUnread(global, message) && !isScheduledList) {
if (!selectIsMessageUnread(global, chatId, threadId, messageId, messageListType)) {
return 'read';
}
const message = selectChatMessage(global, chatId, messageId);
if (!message) {
return 'failed'; // Should never happen
}
return getSendingState(message);
}
@ -514,24 +435,15 @@ export function selectFullWebPageFromMessage<T extends GlobalState>(global: T, m
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;
export function selectCanDeleteOwnerTopic<T extends GlobalState>(global: T, chatId: string, topicId: number) {
const topic = selectTopic(global, chatId, topicId);
if (topic && !topic.isOwner) return false;
const thread = selectThread(global, chatId, topicId);
if (!thread) return false;
const localThreadState = selectThreadLocalState(global, chatId, topicId);
if (!localThreadState) return false;
const { listedIds } = thread;
const { listedIds } = localThreadState;
if (!listedIds
// Plus one for root message
|| listedIds.length + 1 >= MAX_MESSAGES_TO_DELETE_OWNER_TOPIC) {
@ -559,59 +471,6 @@ export function selectCanDeleteTopic<T extends GlobalState>(global: T, chatId: s
&& 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) {
const chat = selectChat(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) {
if (threadId === MAIN_THREAD_ID) {
const chat = selectChat(global, chatId);
if (!chat) {
return undefined;
}
// `lastReadInboxMessageId` is empty for new chats
if (!chat.lastReadInboxMessageId) {
return undefined;
}
const lastMessageId = selectChatLastMessageId(global, chatId);
if (!lastMessageId || chat.unreadCount) {
return chat.lastReadInboxMessageId;
}
return lastMessageId;
} else {
const threadInfo = selectThreadInfo(global, chatId, threadId);
if (!threadInfo) {
return undefined;
}
if (!threadInfo.lastReadInboxMessageId) {
return Number(threadInfo.threadId);
}
// Some previously read messages may be deleted
return Math.min(threadInfo.lastReadInboxMessageId, threadInfo.lastMessageId || Infinity);
const readState = selectThreadReadState(global, chatId, threadId);
if (!readState) {
return undefined;
}
// `lastReadInboxMessageId` is empty for new chats
if (!readState.lastReadInboxMessageId) {
return undefined;
}
const lastMessageId = selectChatLastMessageId(global, chatId);
if (!lastMessageId || readState.unreadCount) {
return readState.lastReadInboxMessageId;
}
return lastMessageId;
}
export function selectFirstUnreadId<T extends GlobalState>(
@ -961,8 +806,9 @@ export function selectFirstUnreadId<T extends GlobalState>(
}
} else {
const threadInfo = selectThreadInfo(global, chatId, threadId);
if (!threadInfo
|| (threadInfo.lastMessageId !== undefined && threadInfo.lastMessageId === threadInfo.lastReadInboxMessageId)) {
const readState = selectThreadReadState(global, chatId, threadId);
if (!threadInfo || !readState
|| (threadInfo.lastMessageId !== undefined && threadInfo.lastMessageId === readState.lastReadInboxMessageId)) {
return undefined;
}
}

View File

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

View File

@ -44,3 +44,7 @@ export function selectShouldHideReadMarks<T extends GlobalState>(global: T) {
export function selectSettingsKeys<T extends GlobalState>(global: T) {
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 { GlobalState } from '../types';
import { selectChat } from './chats';
import { selectThreadIdFromMessage } from './threads';
export function selectTopicsInfo<T extends GlobalState>(global: T, chatId: string): TopicsInfo | undefined {
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) {
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;
} & WithTabId;
markMessagesRead: {
chatId: string;
messageIds: number[];
shouldFetchUnreadReactions?: boolean;
} & WithTabId;
};
loadMessage: {
chatId: string;
messageId: number;
replyOriginForId?: number;
threadUpdate?: {
lastMessageId: number;
isDeleting?: boolean;
};
};
loadMessagesById: {
chatId: string;
@ -1410,12 +1406,14 @@ export interface ActionPayloads {
threadId: ThreadId;
type: MessageListType;
};
fetchUnreadMentions: {
loadUnreadMentions: {
chatId: string;
threadId?: ThreadId;
offsetId?: number;
};
fetchUnreadReactions: {
loadUnreadReactions: {
chatId: string;
threadId?: ThreadId;
offsetId?: number;
};
scheduleForViewsIncrement: {
@ -1441,10 +1439,17 @@ export interface ActionPayloads {
quickReplyId: number;
};
animateUnreadReaction: {
chatId: string;
messageIds: number[];
} & WithTabId;
focusNextReaction: WithTabId | undefined;
focusNextMention: WithTabId | undefined;
focusNextReaction: {
chatId: string;
threadId?: ThreadId;
} & WithTabId;
focusNextMention: {
chatId: string;
threadId?: ThreadId;
} & WithTabId;
readAllReactions: {
chatId: string;
threadId?: ThreadId;
@ -1456,7 +1461,7 @@ export interface ActionPayloads {
markMentionsRead: {
chatId: string;
messageIds: number[];
} & WithTabId;
};
copyMessageLink: {
chatId: string;
messageId: number;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
export default function arePropsShallowEqual(currentProps: AnyLiteral, newProps: AnyLiteral) {
export function areRecordsShallowEqual(currentProps: AnyLiteral, newProps: AnyLiteral) {
if (currentProps === newProps) {
return true;
}
@ -25,6 +25,27 @@ export default function arePropsShallowEqual(currentProps: AnyLiteral, newProps:
return true;
}
export function areArraysShallowEqual(currentProps: unknown[], newProps: unknown[]) {
if (currentProps === newProps) {
return true;
}
if (currentProps.length !== newProps.length) {
return false;
}
return currentProps.every((item, i) => item === newProps[i]);
}
export default function areShallowEqual<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 = '') {
const currentKeys = Object.keys(currentProps);
const currentKeysLength = currentKeys.length;

View File

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

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[],
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[]) {
return keys.reduce((result, 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()];
}
export function compact<T>(array: T[]) {
export function compact<T>(array: (T | Falsy)[]): T[] {
return array.filter(Boolean);
}