diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index c016efef7..662fbab43 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -29,7 +29,7 @@ import type { PhoneCallAction, } from '../../types'; import { - ApiMessageEntityTypes, + ApiMessageEntityTypes, MAIN_THREAD_ID, } from '../../types'; import { @@ -41,7 +41,7 @@ import { SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; -import { pick } from '../../../util/iteratees'; +import { omitUndefined, pick } from '../../../util/iteratees'; import { getServerTime, getServerTimeOffset } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; import { buildPeer } from '../gramjsBuilders'; @@ -178,12 +178,12 @@ export function buildApiMessageWithChatId( const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice && Boolean(mtpMessage.media.extendedMedia); - const isEdited = mtpMessage.editDate && !mtpMessage.editHide; + const isEdited = Boolean(mtpMessage.editDate) && !mtpMessage.editHide; const { inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, isKeyboardSelective, } = buildReplyButtons(mtpMessage, isInvoiceMedia) || {}; const forwardInfo = mtpMessage.fwdFrom && buildApiMessageForwardInfo(mtpMessage.fwdFrom, isChatWithSelf); - const { replies, mediaUnread: isMediaUnread, postAuthor } = mtpMessage; + const { mediaUnread: isMediaUnread, postAuthor } = mtpMessage; const groupedId = mtpMessage.groupedId && String(mtpMessage.groupedId); const isInAlbum = Boolean(groupedId) && !(content.document || content.audio || content.sticker); const shouldHideKeyboardButtons = mtpMessage.replyMarkup instanceof GramJs.ReplyKeyboardHide; @@ -192,8 +192,9 @@ export function buildApiMessageWithChatId( const isProtected = mtpMessage.noforwards || isInvoiceMedia; const isForwardingAllowed = !mtpMessage.noforwards; const emojiOnlyCount = getEmojiOnlyCountForMessage(content, groupedId); + const hasComments = mtpMessage.replies?.comments; - return { + return omitUndefined({ id: mtpMessage.id, chatId, isOutgoing, @@ -209,12 +210,12 @@ export function buildApiMessageWithChatId( reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), emojiOnlyCount, ...(mtpMessage.replyTo && { replyInfo: buildApiReplyInfo(mtpMessage.replyTo) }), - ...(forwardInfo && { forwardInfo }), - ...(isEdited && { isEdited }), - ...(mtpMessage.editDate && { editDate: mtpMessage.editDate }), - ...(isMediaUnread && { isMediaUnread }), - ...(mtpMessage.mentioned && isMediaUnread && { hasUnreadMention: true }), - ...(mtpMessage.mentioned && { isMentioned: true }), + forwardInfo, + isEdited, + editDate: mtpMessage.editDate, + isMediaUnread, + hasUnreadMention: mtpMessage.mentioned && isMediaUnread, + isMentioned: mtpMessage.mentioned, ...(groupedId && { groupedId, isInAlbum, @@ -225,11 +226,11 @@ export function buildApiMessageWithChatId( }), ...(shouldHideKeyboardButtons && { shouldHideKeyboardButtons, isHideKeyboardSelective }), ...(mtpMessage.viaBotId && { viaBotId: buildApiPeerId(mtpMessage.viaBotId, 'user') }), - ...(replies && { repliesThreadInfo: buildThreadInfo(replies, mtpMessage.id, chatId) }), - ...(postAuthor && { postAuthorTitle: postAuthor }), + postAuthorTitle: postAuthor, isProtected, isForwardingAllowed, - }; + hasComments, + } satisfies ApiMessage); } export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | undefined { @@ -830,8 +831,11 @@ export function buildLocalForwardedMessage({ text: !shouldHideText ? strippedText : undefined, }; - const replyInfo: ApiReplyInfo | undefined = toThreadId ? { + // TODO Prepare reply info between forwarded messages locally, to prevent height jumps + const isToMainThread = toThreadId === MAIN_THREAD_ID; + const replyInfo: ApiReplyInfo | undefined = toThreadId && !isToMainThread ? { type: 'message', + replyToMsgId: toThreadId, replyToTopId: toThreadId, isForumTopic: toChat.isForum || undefined, } : undefined; @@ -968,7 +972,21 @@ function buildUploadingMedia( }; } -function buildThreadInfo( +export function buildApiThreadInfoFromMessage( + mtpMessage: GramJs.TypeMessage, +): ApiThreadInfo | undefined { + const chatId = resolveMessageApiChatId(mtpMessage); + if ( + !chatId + || !(mtpMessage instanceof GramJs.Message) + || !mtpMessage.replies) { + return undefined; + } + + return buildApiThreadInfo(mtpMessage.replies, mtpMessage.id, chatId); +} + +export function buildApiThreadInfo( messageReplies: GramJs.TypeMessageReplies, messageId: number, chatId: string, ): ApiThreadInfo | undefined { const { @@ -980,21 +998,28 @@ function buildThreadInfo( return undefined; } - const isPostThread = apiChannelId && chatId !== apiChannelId; + const baseThreadInfo = { + messagesCount: replies, + ...(maxId && { lastMessageId: maxId }), + ...(readMaxId && { lastReadMessageId: readMaxId }), + ...(recentRepliers && { recentReplierIds: recentRepliers.map(getApiChatIdFromMtpPeer) }), + }; + + if (comments) { + return { + ...baseThreadInfo, + isCommentsInfo: true, + chatId: apiChannelId!, + originChannelId: chatId, + originMessageId: messageId, + }; + } return { - isComments: comments, + ...baseThreadInfo, + isCommentsInfo: false, + chatId, threadId: messageId, - ...(isPostThread ? { - chatId: apiChannelId, - originChannelId: chatId, - } : { - chatId, - }), - messagesCount: replies, - lastMessageId: maxId, - lastReadInboxMessageId: readMaxId, - ...(recentRepliers && { recentReplierIds: recentRepliers.map(getApiChatIdFromMtpPeer) }), }; } diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index c64aacb57..6aa1aa32f 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -72,6 +72,7 @@ import { import localDb from '../localDb'; import { scheduleMutedChatUpdate } from '../scheduleUnmute'; import { applyState, processUpdate, updateChannelState } from '../updateManager'; +import { dispatchThreadInfoUpdates } from '../updater'; import { invokeRequest, uploadFile } from './client'; type FullChatData = { @@ -130,6 +131,8 @@ export async function fetchChats({ 'chatId', ); + dispatchThreadInfoUpdates(result.messages); + const peersByKey = preparePeers(result); if (resultPinned) { Object.assign(peersByKey, preparePeers(resultPinned, peersByKey)); @@ -340,6 +343,8 @@ export async function requestChatUpdate({ updateLocalDb(result); const lastRemoteMessage = buildApiMessage(result.messages[0]); + dispatchThreadInfoUpdates(result.messages); + const lastMessage = lastLocalMessage && (!lastRemoteMessage || (lastLocalMessage.date > lastRemoteMessage.date)) ? lastLocalMessage : lastRemoteMessage; @@ -542,7 +547,7 @@ async function getFullChannelInfo( kickedMembers, adminMembersById: adminMembers ? buildCollectionByKey(adminMembers, 'userId') : undefined, groupCallId: call ? String(call.id) : undefined, - linkedChatId: linkedChatId ? buildApiPeerId(linkedChatId, 'chat') : undefined, + linkedChatId: linkedChatId ? buildApiPeerId(linkedChatId, 'channel') : undefined, botCommands, enabledReactions: buildApiChatReactions(availableReactions), sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined, @@ -1584,6 +1589,7 @@ export async function fetchTopics({ const topics = result.topics.map(buildApiTopic).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); const users = result.users.map(buildApiUser).filter(Boolean); const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); const draftsById = result.topics.reduce((acc, topic) => { @@ -1637,6 +1643,7 @@ export async function fetchTopicById({ updateLocalDb(result); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); const users = result.users.map(buildApiUser).filter(Boolean); const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 03cc126f4..ab534b3c9 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -28,12 +28,12 @@ export { export { fetchMessages, fetchMessage, sendMessage, pinMessage, unpinAllMessages, deleteMessages, deleteHistory, - markMessageListRead, markMessagesRead, requestThreadInfoUpdate, searchMessagesLocal, searchMessagesGlobal, + markMessageListRead, markMessagesRead, searchMessagesLocal, searchMessagesGlobal, fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate, fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages, reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs, saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, transcribeAudio, - closePoll, fetchExtendedMedia, translateText, fetchMessageViews, + closePoll, fetchExtendedMedia, translateText, fetchMessageViews, fetchDiscussionMessage, } from './messages'; export { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index daef0d0e6..a42abe6c6 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -49,6 +49,8 @@ import { import { buildApiMessage, buildApiSponsoredMessage, + buildApiThreadInfo, + buildApiThreadInfoFromMessage, buildLocalForwardedMessage, buildLocalMessage, } from '../apiBuilders/messages'; @@ -76,9 +78,9 @@ import { addEntitiesToLocalDb, addMessageToLocalDb, deserializeBytes, - resolveMessageApiChatId, } from '../helpers'; import { updateChannelState } from '../updateManager'; +import { dispatchThreadInfoUpdates } from '../updater'; import { requestChatUpdate } from './chats'; import { handleGramJsUpdate, invokeRequest, uploadFile } from './client'; @@ -156,13 +158,12 @@ 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 repliesThreadInfos = messages.map(({ repliesThreadInfo }) => repliesThreadInfo).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); return { messages, users, chats, - repliesThreadInfos, }; } @@ -220,6 +221,8 @@ export async function fetchMessage({ chat, messageId }: { chat: ApiChat; message } const message = mtpMessage && buildApiMessage(mtpMessage); + dispatchThreadInfoUpdates([mtpMessage]); + if (!message) { return undefined; } @@ -857,8 +860,6 @@ export async function markMessageListRead({ if (threadId === MAIN_THREAD_ID) { void requestChatUpdate({ chat, noLastMessage: true }); - } else { - void requestThreadInfoUpdate({ chat, threadId }); } } @@ -923,10 +924,7 @@ export async function fetchMessageViews({ id, views, forwards, - messagesCount: replies?.replies, - recentReplierIds: replies?.recentRepliers?.map(getApiChatIdFromMtpPeer), - maxId: replies?.maxId, - readMaxId: replies?.readMaxId, + threadInfo: replies ? buildApiThreadInfo(replies, id, chat.id) : undefined, }; }); @@ -937,94 +935,73 @@ export async function fetchMessageViews({ }; } -export async function requestThreadInfoUpdate({ - chat, threadId, originChannelId, +export async function fetchDiscussionMessage({ + chat, messageId, }: { - chat: ApiChat; threadId: number; originChannelId?: string; + chat: ApiChat; + messageId: number; }) { - if (threadId === MAIN_THREAD_ID) { - return undefined; - } - - const [topMessageResult, repliesResult] = await Promise.all([ + const [result, replies] = await Promise.all([ invokeRequest(new GramJs.messages.GetDiscussionMessage({ peer: buildInputPeer(chat.id, chat.accessHash), - msgId: Number(threadId), - })), - invokeRequest(new GramJs.messages.GetReplies({ - peer: buildInputPeer(chat.id, chat.accessHash), - msgId: Number(threadId), + msgId: messageId, + }), { + abortControllerChatId: chat.id, + abortControllerThreadId: messageId, + }), + fetchMessages({ + chat, + threadId: messageId, offsetId: 1, addOffset: -1, limit: 1, - })), + }), ]); - if (!topMessageResult || !topMessageResult.messages.length) { - return undefined; - } + if (!result || !replies) return undefined; - const discussionChatId = resolveMessageApiChatId(topMessageResult.messages[0]); - if (!discussionChatId) { - return undefined; - } + updateLocalDb(result); - const topMessageId = topMessageResult.messages[topMessageResult.messages.length - 1].id; + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean) + .concat(replies.chats); + const users = result.users.map(buildApiUser).filter(Boolean) + .concat(replies.users); + const topMessages = result.messages.map(buildApiMessage).filter(Boolean); + const messages = topMessages.concat(replies.messages); + const threadId = result.messages[result.messages.length - 1]?.id; - onUpdate({ - '@type': 'updateThreadInfo', - chatId: discussionChatId, - threadId: topMessageId, - threadInfo: { - threadId: topMessageId, - topMessageId, - lastReadInboxMessageId: topMessageResult.readInboxMaxId, - messagesCount: (repliesResult instanceof GramJs.messages.ChannelMessages) ? repliesResult.count : undefined, - lastMessageId: topMessageResult.maxId, - ...(originChannelId ? { originChannelId } : undefined), - }, - firstMessageId: repliesResult && 'messages' in repliesResult && repliesResult.messages.length - ? repliesResult.messages[0].id - : undefined, - }); + if (!threadId) return undefined; - const chats = topMessageResult.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); - chats.forEach((newChat) => { - onUpdate({ - '@type': 'updateChat', - id: newChat.id, - chat: newChat, - noTopChatsRequest: true, - }); - }); + dispatchThreadInfoUpdates(result.messages); + const threadInfoUpdates = result.messages.map(buildApiThreadInfoFromMessage).filter(Boolean); - if (chat.isForum) { - onUpdate({ - '@type': 'updateTopic', - chatId: chat.id, - topicId: threadId, - }); - } - - addEntitiesToLocalDb(topMessageResult.users); - addEntitiesToLocalDb(topMessageResult.chats); - - const users = topMessageResult.users.map(buildApiUser).filter(Boolean); + const { + unreadCount, maxId, readInboxMaxId, readOutboxMaxId, + } = result; return { - topMessageId, - discussionChatId, + chats, users, + messages, + topMessages, + unreadCount, + threadId, + lastReadInboxMessageId: readInboxMaxId, + lastReadOutboxMessageId: readOutboxMaxId, + lastMessageId: maxId, + chatId: topMessages[0]?.chatId, + firstMessageId: replies.messages[0]?.id, + threadInfoUpdates, }; } export async function searchMessagesLocal({ - chat, type, query, topMessageId, minDate, maxDate, ...pagination + chat, type, query, threadId, minDate, maxDate, ...pagination }: { chat: ApiChat; type?: ApiMessageSearchType | ApiGlobalMessageSearchType; query?: string; - topMessageId?: number; + threadId?: number; offsetId?: number; addOffset?: number; limit: number; @@ -1059,7 +1036,7 @@ export async function searchMessagesLocal({ const result = await invokeRequest(new GramJs.messages.Search({ peer: buildInputPeer(chat.id, chat.accessHash), - topMsgId: topMessageId, + topMsgId: threadId === MAIN_THREAD_ID ? undefined : threadId, filter, q: query || '', minDate, @@ -1067,7 +1044,7 @@ export async function searchMessagesLocal({ ...pagination, }), { abortControllerChatId: chat.id, - abortControllerThreadId: topMessageId, + abortControllerThreadId: threadId, }); if ( @@ -1083,6 +1060,7 @@ export async function searchMessagesLocal({ const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); let totalCount = messages.length; let nextOffsetId: number | undefined; @@ -1168,6 +1146,7 @@ export async function searchMessagesGlobal({ const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); let totalCount = messages.length; let nextRate: number | undefined; @@ -1417,6 +1396,7 @@ export async function fetchScheduledHistory({ chat }: { chat: ApiChat }) { updateLocalDb(result); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); return { messages, @@ -1475,6 +1455,7 @@ export async function fetchPinnedMessages({ chat, threadId }: { chat: ApiChat; t const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); return { messages, @@ -1617,6 +1598,7 @@ export async function fetchUnreadMentions({ updateLocalDb(result); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); const users = result.users.map(buildApiUser).filter(Boolean); const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); @@ -1653,6 +1635,7 @@ export async function fetchUnreadReactions({ updateLocalDb(result); const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); const users = result.users.map(buildApiUser).filter(Boolean); const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 89a207aec..0c2f13cba 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -7,7 +7,7 @@ import type { } from '../types'; import { DEBUG, GENERAL_TOPIC_ID } from '../../config'; -import { omit, pick } from '../../util/iteratees'; +import { compact, omit, pick } from '../../util/iteratees'; import { getServerTimeOffset, setServerTimeOffset } from '../../util/serverTime'; import { buildApiBotMenuButton } from './apiBuilders/bots'; import { @@ -38,6 +38,7 @@ import { buildApiMessageFromNotification, buildApiMessageFromShort, buildApiMessageFromShortChat, + buildApiThreadInfoFromMessage, buildMessageDraft, } from './apiBuilders/messages'; import { @@ -125,6 +126,16 @@ export function dispatchUserAndChatUpdates(entities: (GramJs.TypeUser | GramJs.T }); } +export function dispatchThreadInfoUpdates(messages: (GramJs.TypeMessage | undefined)[]) { + const threadInfoUpdates = compact(messages).map(buildApiThreadInfoFromMessage).filter(Boolean); + if (!threadInfoUpdates.length) return; + + onUpdate({ + '@type': 'updateThreadInfos', + threadInfoUpdates, + }); +} + export function sendUpdate(update: ApiUpdate) { onUpdate(update); } @@ -199,6 +210,8 @@ export function updater(update: Update) { } message = buildApiMessage(update.message)!; + dispatchThreadInfoUpdates([update.message]); + shouldForceReply = 'replyMarkup' in update.message && update.message?.replyMarkup instanceof GramJs.ReplyKeyboardForceReply && (!update.message.replyMarkup.selective || message.isMentioned); @@ -348,6 +361,7 @@ export function updater(update: Update) { // Workaround for a weird server behavior when own message is marked as incoming const message = omit(buildApiMessage(update.message)!, ['isOutgoing']); + dispatchThreadInfoUpdates([update.message]); onUpdate({ '@type': 'updateMessage', @@ -548,12 +562,12 @@ export function updater(update: Update) { }); } else if (update instanceof GramJs.UpdateReadChannelDiscussionInbox) { onUpdate({ - '@type': 'updateThreadInfo', - chatId: buildApiPeerId(update.channelId, 'channel'), - threadId: update.topMsgId, - threadInfo: { + '@type': 'updateThreadInfos', + threadInfoUpdates: [{ + chatId: buildApiPeerId(update.channelId, 'channel'), + threadId: update.topMsgId, lastReadInboxMessageId: update.readMaxId, - }, + }], }); } else if (update instanceof GramJs.UpdateReadChannelDiscussionOutbox) { onUpdate({ diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 39516d96f..71d27d181 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -480,7 +480,6 @@ export interface ApiMessage { isKeyboardSingleUse?: boolean; isKeyboardSelective?: boolean; viaBotId?: string; - repliesThreadInfo?: ApiThreadInfo; postAuthorTitle?: string; isScheduled?: boolean; shouldHideKeyboardButtons?: boolean; @@ -500,6 +499,7 @@ export interface ApiMessage { reactions: ApiPeerReaction[]; }; reactions?: ApiReactions; + hasComments?: boolean; } export interface ApiReactions { @@ -559,18 +559,31 @@ export type ApiReactionCustomEmoji = { export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji; -export interface ApiThreadInfo { - isComments?: boolean; - threadId: number; +interface ApiBaseThreadInfo { chatId: string; - topMessageId?: number; - originChannelId?: string; messagesCount: number; lastMessageId?: number; lastReadInboxMessageId?: number; recentReplierIds?: string[]; } +export interface ApiCommentsInfo extends ApiBaseThreadInfo { + isCommentsInfo: true; + threadId?: number; + originChannelId: string; + originMessageId: number; +} + +export interface ApiMessageThreadInfo extends ApiBaseThreadInfo { + isCommentsInfo: false; + threadId: number; + // For linked messages in discussion + fromChannelId?: string; + fromMessageId?: number; +} + +export type ApiThreadInfo = ApiCommentsInfo | ApiMessageThreadInfo; + export type ApiMessageOutgoingStatus = 'read' | 'succeeded' | 'pending' | 'failed'; export type ApiSponsoredMessage = { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 6a29af4a3..b86dea65e 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -222,12 +222,9 @@ export type ApiUpdatePinnedMessageIds = { messageIds: number[]; }; -export type ApiUpdateThreadInfo = { - '@type': 'updateThreadInfo'; - chatId: string; - threadId: number; - threadInfo: Partial; - firstMessageId?: number; +export type ApiUpdateThreadInfos = { + '@type': 'updateThreadInfos'; + threadInfoUpdates: Partial[]; }; export type ApiUpdateScheduledMessageSendSucceeded = { @@ -685,7 +682,7 @@ export type ApiUpdate = ( ApiUpdateChat | ApiUpdateChatInbox | ApiUpdateChatTypingStatus | ApiUpdateChatFullInfo | ApiUpdatePinnedChatIds | ApiUpdateChatMembers | ApiUpdateChatJoin | ApiUpdateChatLeave | ApiUpdateChatPinned | ApiUpdatePinnedMessageIds | ApiUpdateChatListType | ApiUpdateChatFolder | ApiUpdateChatFoldersOrder | ApiUpdateRecommendedChatFolders | - ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfo | ApiUpdateCommonBoxMessages | ApiUpdateChannelMessages | + ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfos | ApiUpdateCommonBoxMessages | ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory | ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos | diff --git a/src/components/common/ChatForumLastMessage.tsx b/src/components/common/ChatForumLastMessage.tsx index 3f1ec8c26..779e44bc8 100644 --- a/src/components/common/ChatForumLastMessage.tsx +++ b/src/components/common/ChatForumLastMessage.tsx @@ -38,7 +38,7 @@ const ChatForumLastMessage: FC = ({ renderLastMessage, observeIntersection, }) => { - const { openChat } = getActions(); + const { openThread } = getActions(); // eslint-disable-next-line no-null/no-null const lastMessageRef = useRef(null); @@ -67,8 +67,8 @@ const ChatForumLastMessage: FC = ({ e.stopPropagation(); e.preventDefault(); - openChat({ - id: chat.id, + openThread({ + chatId: chat.id, threadId: lastActiveTopic.id, shouldReplaceHistory: true, noForumTopicPanel: getIsMobile(), diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 079c16eee..cf725e30e 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -349,7 +349,7 @@ const Composer: FC = ({ openPollModal, closePollModal, loadScheduledHistory, - openChat, + openThread, addRecentEmoji, sendInlineBotResult, loadSendAs, @@ -1246,8 +1246,8 @@ const Composer: FC = ({ }); const handleAllScheduledClick = useLastCallback(() => { - openChat({ - id: chatId, threadId, type: 'scheduled', noForumTopicPanel: true, + openThread({ + chatId, threadId, type: 'scheduled', noForumTopicPanel: true, }); }); diff --git a/src/components/common/StickerSetModal.tsx b/src/components/common/StickerSetModal.tsx index aa357c931..82b06f62a 100644 --- a/src/components/common/StickerSetModal.tsx +++ b/src/components/common/StickerSetModal.tsx @@ -260,9 +260,9 @@ export default memo(withGlobal( const chat = chatId && selectChat(global, chatId); const sendOptions = chat ? getAllowedAttachmentOptions(chat) : undefined; const threadInfo = chatId && threadId ? selectThreadInfo(global, chatId, threadId) : undefined; - const isComments = Boolean(threadInfo?.originChannelId); + const isMessageThread = Boolean(!threadInfo?.isCommentsInfo && threadInfo?.fromChannelId); const canSendStickers = Boolean( - chat && threadId && getCanPostInChat(chat, threadId, isComments) && sendOptions?.canSendStickers, + chat && threadId && getCanPostInChat(chat, threadId, isMessageThread) && sendOptions?.canSendStickers, ); const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId); diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/ForumPanel.tsx index 7fa507899..441f8f8c1 100644 --- a/src/components/left/main/ForumPanel.tsx +++ b/src/components/left/main/ForumPanel.tsx @@ -19,7 +19,6 @@ import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { captureEvents, SwipeDirection } from '../../../util/captureEvents'; import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners'; -import { createLocationHash } from '../../../util/routing'; import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; import useAppLayout from '../../../hooks/useAppLayout'; @@ -139,7 +138,6 @@ const ForumPanel: FC = ({ useHistoryBack({ isActive: isVisible, onBack: handleClose, - hash: chat ? createLocationHash(chat.id, 'thread', MAIN_THREAD_ID) : undefined, }); useEffect(() => (isVisible ? captureEscKeyListener(handleClose) : undefined), [handleClose, isVisible]); diff --git a/src/components/left/main/Topic.tsx b/src/components/left/main/Topic.tsx index 359b76c61..01e1a665e 100644 --- a/src/components/left/main/Topic.tsx +++ b/src/components/left/main/Topic.tsx @@ -92,7 +92,7 @@ const Topic: FC = ({ draft, wasTopicOpened, }) => { - const { openChat, deleteTopic, focusLastMessage } = getActions(); + const { openThread, deleteTopic, focusLastMessage } = getActions(); const lang = useLang(); @@ -140,7 +140,7 @@ const Topic: FC = ({ }); const handleOpenTopic = useLastCallback(() => { - openChat({ id: chatId, threadId: topic.id, shouldReplaceHistory: true }); + openThread({ chatId, threadId: topic.id, shouldReplaceHistory: true }); if (canScrollDown) { focusLastMessage(); diff --git a/src/components/left/search/ChatMessageResults.tsx b/src/components/left/search/ChatMessageResults.tsx index a86ea4d66..b4d5e0438 100644 --- a/src/components/left/search/ChatMessageResults.tsx +++ b/src/components/left/search/ChatMessageResults.tsx @@ -50,7 +50,7 @@ const ChatMessageResults: FC = ({ onSearchDateSelect, onReset, }) => { - const { searchMessagesGlobal, openChat } = getActions(); + const { searchMessagesGlobal, openThread } = getActions(); const lang = useLang(); const { isMobile } = useAppLayout(); @@ -68,13 +68,14 @@ const ChatMessageResults: FC = ({ const handleTopicClick = useCallback( (id: number) => { - openChat({ id: searchChatId, threadId: id, shouldReplaceHistory: true }); + if (!searchChatId) return; + openThread({ chatId: searchChatId, threadId: id, shouldReplaceHistory: true }); if (!isMobile) { onReset(); } }, - [openChat, searchChatId, isMobile, onReset], + [searchChatId, isMobile, onReset], ); const foundMessages = useMemo(() => { diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 770979b9b..a4f62f3e3 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -256,7 +256,7 @@ const Main: FC = ({ closePaymentModal, clearReceipt, checkAppVersion, - openChat, + openThread, toggleLeftColumn, loadRecentEmojiStatuses, updatePageTitle, @@ -419,8 +419,8 @@ const Main: FC = ({ const parsedLocationHash = parseLocationHash(); if (!parsedLocationHash) return; - openChat({ - id: parsedLocationHash.chatId, + openThread({ + chatId: parsedLocationHash.chatId, threadId: parsedLocationHash.threadId, type: parsedLocationHash.type, }); diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index 39cf31470..81f4a8766 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -208,7 +208,7 @@ const HeaderActions: FC = ({ }); const handleAsMessagesClick = useLastCallback(() => { - openChat({ id: chatId, threadId: MAIN_THREAD_ID }); + openChat({ id: chatId }); }); function handleRequestCall() { diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 480437f7c..ccf0b8f4f 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -181,7 +181,7 @@ const HeaderMenuContainer: FC = ({ toggleStatistics, openBoostStatistics, openGiftPremiumModal, - openChatWithInfo, + openThreadWithInfo, openCreateTopicPanel, openEditTopicPanel, openChat, @@ -231,7 +231,7 @@ const HeaderMenuContainer: FC = ({ }); const handleViewGroupInfo = useLastCallback(() => { - openChatWithInfo({ id: chatId, threadId }); + openThreadWithInfo({ chatId, threadId }); setShouldCloseFast(!isRightColumnShown); closeMenu(); }); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index c3bc09f02..215139b14 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -49,7 +49,6 @@ import { selectScrollOffset, selectTabState, selectThreadInfo, - selectThreadTopMessageId, } from '../../global/selectors'; import animateScroll, { isAnimatingScroll, restartCurrentScrollAnimation } from '../../util/animateScroll'; import buildClassName from '../../util/buildClassName'; @@ -83,6 +82,7 @@ type OwnProps = { chatId: string; threadId: number; type: MessageListType; + isComments?: boolean; canPost: boolean; isReady: boolean; onFabToggle: (shouldShow: boolean) => void; @@ -106,18 +106,18 @@ type StateProps = { messageIds?: number[]; messagesById?: Record; firstUnreadId?: number; - isComments?: boolean; isViewportNewest?: boolean; isRestricted?: boolean; restrictionReason?: ApiRestrictionReason; focusingId?: number; isSelectModeActive?: boolean; lastMessage?: ApiMessage; - threadTopMessageId?: number; hasLinkedChat?: boolean; topic?: ApiTopic; noMessageSendingAnimation?: boolean; isServiceNotificationsChat?: boolean; + isEmptyThread?: boolean; + isForum?: boolean; }; const MESSAGE_REACTIONS_POLLING_INTERVAL = 20 * 1000; @@ -143,6 +143,7 @@ const MessageList: FC = ({ onNotchToggle, isCurrentUserPremium, isChatLoaded, + isForum, isChannelChat, isGroupChat, canPost, @@ -158,10 +159,10 @@ const MessageList: FC = ({ isViewportNewest, isRestricted, restrictionReason, + isEmptyThread, focusingId, isSelectModeActive, lastMessage, - threadTopMessageId, hasLinkedChat, withBottomShift, withDefaultBg, @@ -244,15 +245,20 @@ const MessageList: FC = ({ : ['id']; return listedMessages.length - ? groupMessages(orderBy(listedMessages, orderRule), memoUnreadDividerBeforeIdRef.current, isChatWithSelf) + ? groupMessages( + orderBy(listedMessages, orderRule), + memoUnreadDividerBeforeIdRef.current, + !isForum ? threadId : undefined, + isChatWithSelf, + ) : undefined; - }, [messageIds, messagesById, type, isServiceNotificationsChat, isChatWithSelf]); + }, [messageIds, messagesById, type, isServiceNotificationsChat, isForum, threadId, isChatWithSelf]); useInterval(() => { if (!messageIds || !messagesById || type === 'scheduled') { return; } - const ids = messageIds.filter((id) => messagesById[id]?.reactions); + const ids = messageIds.filter((id) => messagesById[id]?.reactions?.results.length); if (!ids.length) return; @@ -285,7 +291,8 @@ const MessageList: FC = ({ if (!messageIds || !messagesById || threadId !== MAIN_THREAD_ID || type === 'scheduled') { return; } - const ids = messageIds.filter((id) => messagesById[id]?.repliesThreadInfo?.isComments + const global = getGlobal(); + const ids = messageIds.filter((id) => selectThreadInfo(global, chatId, id)?.isCommentsInfo || messagesById[id]?.views !== undefined); if (!ids.length) return; @@ -606,6 +613,7 @@ const MessageList: FC = ({ getContainerHeight={getContainerHeight} isViewportNewest={Boolean(isViewportNewest)} isUnread={Boolean(firstUnreadId)} + isEmptyThread={isEmptyThread} withUsers={withUsers} noAvatars={noAvatars} containerRef={containerRef} @@ -615,7 +623,6 @@ const MessageList: FC = ({ threadId={threadId} type={type} isReady={isReady} - threadTopMessageId={threadTopMessageId} hasLinkedChat={hasLinkedChat} isSchedule={messageGroups ? type === 'scheduled' : false} shouldRenderBotInfo={isBot} @@ -642,12 +649,10 @@ export default memo(withGlobal( const messagesById = type === 'scheduled' ? selectChatScheduledMessages(global, chatId) : selectChatMessages(global, chatId); - const threadTopMessageId = selectThreadTopMessageId(global, chatId, threadId); - const threadInfo = selectThreadInfo(global, chatId, threadId); if ( threadId !== MAIN_THREAD_ID && !chat?.isForum - && !(messagesById && threadTopMessageId && messagesById[threadTopMessageId]) + && !(messagesById && threadId && messagesById[threadId]) ) { return {}; } @@ -664,6 +669,7 @@ export default memo(withGlobal( const topic = chat.topics?.[threadId]; const chatFullInfo = !isUserId(chatId) ? selectChatFullInfo(global, chatId) : undefined; + const isEmptyThread = !selectThreadInfo(global, chatId, threadId)?.messagesCount; return { isCurrentUserPremium: selectIsCurrentUserPremium(global), @@ -678,16 +684,16 @@ export default memo(withGlobal( isBot: Boolean(chatBot), messageIds, messagesById, - isComments: Boolean(threadInfo?.originChannelId), firstUnreadId: selectFirstUnreadId(global, chatId, threadId), isViewportNewest: type !== 'thread' || selectIsViewportNewest(global, chatId, threadId), focusingId, isSelectModeActive: selectIsInSelectMode(global), - threadTopMessageId, hasLinkedChat: chatFullInfo ? Boolean(chatFullInfo.linkedChatId) : undefined, topic, noMessageSendingAnimation: !selectPerformanceSettingsValue(global, 'messageSendingAnimations'), isServiceNotificationsChat: chatId === SERVICE_NOTIFICATIONS_USER_ID, + isForum: chat.isForum, + isEmptyThread, ...(withLastMessageWhenPreloading && { lastMessage }), }; }, diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 6992d68c8..027133787 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -41,6 +41,7 @@ interface OwnProps { isUnread: boolean; withUsers: boolean; isChannelChat: boolean | undefined; + isEmptyThread?: boolean; isComments?: boolean; noAvatars: boolean; containerRef: RefObject; @@ -49,7 +50,6 @@ interface OwnProps { memoFirstUnreadIdRef: { current: number | undefined }; type: MessageListType; isReady: boolean; - threadTopMessageId: number | undefined; hasLinkedChat: boolean | undefined; isSchedule: boolean; shouldRenderBotInfo?: boolean; @@ -71,6 +71,7 @@ const MessageListContent: FC = ({ isViewportNewest, isUnread, isComments, + isEmptyThread, withUsers, isChannelChat, noAvatars, @@ -80,7 +81,6 @@ const MessageListContent: FC = ({ memoFirstUnreadIdRef, type, isReady, - threadTopMessageId, hasLinkedChat, isSchedule, shouldRenderBotInfo, @@ -193,6 +193,7 @@ const MessageListContent: FC = ({ const documentGroupId = !isMessageAlbum && message.groupedId ? message.groupedId : undefined; const nextDocumentGroupId = nextMessage && !isAlbum(nextMessage) ? nextMessage.groupedId : undefined; + const isTopicTopMessage = message.id === threadId; const position = { isFirstInGroup: messageIndex === 0, @@ -214,8 +215,6 @@ const MessageListContent: FC = ({ const noComments = hasLinkedChat === false || !isChannelChat; - const isTopicTopMessage = message.id === threadTopMessageId; - return compact([ message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider, = ({ onPinnedIntersectionChange={onPinnedIntersectionChange} getIsMessageListReady={getIsReady} />, - message.id === threadTopMessageId && ( + message.id === threadId && (
- {lang('DiscussionStarted')} + {lang(isEmptyThread + ? (isComments ? 'NoComments' : 'NoReplies') : 'DiscussionStarted')} +
), ]); diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index e0fa9b067..cb97124b4 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -53,7 +53,6 @@ import { selectTabState, selectTheme, selectThreadInfo, - selectThreadTopMessageId, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; @@ -103,6 +102,7 @@ interface OwnProps { type StateProps = { chatId?: string; threadId?: number; + isComments?: boolean; messageListType?: MessageListType; chat?: ApiChat; draftReplyInfo?: ApiInputMessageReplyInfo; @@ -157,6 +157,7 @@ function MiddleColumn({ leftColumnRef, chatId, threadId, + isComments, messageListType, isMobile, chat, @@ -510,6 +511,7 @@ function MiddleColumn({ chatId={renderingChatId!} threadId={renderingThreadId!} messageListType={renderingMessageListType!} + isComments={isComments} isReady={isReady} isMobile={isMobile} getCurrentPinnedIndexes={getCurrentPinnedIndexes} @@ -528,6 +530,7 @@ function MiddleColumn({ chatId={renderingChatId!} threadId={renderingThreadId!} type={renderingMessageListType!} + isComments={isComments} canPost={renderingCanPost!} hasTools={renderingHasTools} onFabToggle={setIsFabShown} @@ -734,8 +737,8 @@ export default memo(withGlobal( const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; const threadInfo = selectThreadInfo(global, chatId, threadId); - const isComments = Boolean(threadInfo?.originChannelId); - const canPost = chat && getCanPostInChat(chat, threadId, isComments); + const isMessageThread = Boolean(!threadInfo?.isCommentsInfo && threadInfo?.fromChannelId); + const canPost = chat && getCanPostInChat(chat, threadId, isMessageThread); const isBotNotStarted = selectIsChatBotNotStarted(global, chatId); const isPinnedMessageList = messageListType === 'pinned'; const isMainThread = messageListType === 'thread' && threadId === MAIN_THREAD_ID; @@ -761,7 +764,7 @@ export default memo(withGlobal( : undefined; const isCommentThread = threadId !== MAIN_THREAD_ID && !chat?.isForum; - const topMessageId = isCommentThread ? selectThreadTopMessageId(global, chatId, threadId) : undefined; + const topMessageId = isCommentThread ? threadId : undefined; const canUnpin = chat && ( isPrivate || ( @@ -779,6 +782,7 @@ export default memo(withGlobal( draftReplyInfo, isPrivate, areChatSettingsLoaded: Boolean(chat?.settings), + isComments: isMessageThread, canPost: !isPinnedMessageList && (!chat || canPost) && !isBotNotStarted diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index f46d950f9..931fce55d 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -47,7 +47,6 @@ import { selectTabState, selectThreadInfo, selectThreadParam, - selectThreadTopMessageId, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import cycleRestrict from '../../util/cycleRestrict'; @@ -86,6 +85,7 @@ type OwnProps = { chatId: string; threadId: number; messageListType: MessageListType; + isComments?: boolean; isReady?: boolean; isMobile?: boolean; getCurrentPinnedIndexes: Signal>; @@ -105,7 +105,6 @@ type StateProps = { isRightColumnShown?: boolean; audioMessage?: ApiMessage; messagesCount?: number; - isComments?: boolean; isChatWithSelf?: boolean; hasButtonInHeader?: boolean; shouldSkipHistoryAnimations?: boolean; @@ -147,7 +146,7 @@ const MiddleHeader: FC = ({ onFocusPinnedMessage, }) => { const { - openChatWithInfo, + openThreadWithInfo, pinMessage, focusMessage, openChat, @@ -156,6 +155,7 @@ const MiddleHeader: FC = ({ toggleLeftColumn, exitMessageSelectMode, openPremiumModal, + openThread, } = getActions(); const lang = useLang(); @@ -197,7 +197,7 @@ const MiddleHeader: FC = ({ } = useFastClick((e: React.MouseEvent) => { if (e.type === 'mousedown' && (e.target as Element).closest('.title > .custom-emoji')) return; - openChatWithInfo({ id: chatId, threadId }); + openThreadWithInfo({ chatId, threadId }); }); const handleUnpinMessage = useLastCallback((messageId: number) => { @@ -217,7 +217,7 @@ const MiddleHeader: FC = ({ }); const handleAllPinnedClick = useLastCallback(() => { - openChat({ id: chatId, threadId, type: 'pinned' }); + openThread({ chatId, threadId, type: 'pinned' }); }); const setBackButtonActive = useLastCallback(() => { @@ -354,7 +354,9 @@ const MiddleHeader: FC = ({

{messagesCount !== undefined ? ( messageListType === 'thread' ? ( - lang(isComments ? 'CommentsCount' : 'Replies', messagesCount, 'i')) + (messagesCount + ? lang(isComments ? 'Comments' : 'Replies', messagesCount, 'i') + : lang(isComments ? 'CommentsTitle' : 'RepliesTitle'))) : messageListType === 'pinned' ? (lang('PinnedMessagesCount', messagesCount, 'i')) : messageListType === 'scheduled' ? ( isChatWithSelf ? lang('Reminders') : lang('messages', messagesCount, 'i') @@ -560,10 +562,9 @@ export default memo(withGlobal( } if (threadId !== MAIN_THREAD_ID && !chat?.isForum) { - const pinnedMessageId = selectThreadTopMessageId(global, chatId, threadId); + const pinnedMessageId = threadId; const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined; const topMessageSender = message ? selectForwardedSender(global, message) : undefined; - const threadInfo = selectThreadInfo(global, chatId, threadId); return { ...state, @@ -571,7 +572,6 @@ export default memo(withGlobal( messagesById, canUnpin: false, topMessageSender, - isComments: Boolean(threadInfo?.originChannelId), }; } diff --git a/src/components/middle/helpers/groupMessages.ts b/src/components/middle/helpers/groupMessages.ts index fb98f169c..489d28b5c 100644 --- a/src/components/middle/helpers/groupMessages.ts +++ b/src/components/middle/helpers/groupMessages.ts @@ -18,7 +18,9 @@ export function isAlbum(messageOrAlbum: ApiMessage | IAlbum): messageOrAlbum is return 'albumId' in messageOrAlbum; } -export function groupMessages(messages: ApiMessage[], firstUnreadId?: number, isChatWithSelf = false) { +export function groupMessages( + messages: ApiMessage[], firstUnreadId?: number, topMessageId?: number, isChatWithSelf?: boolean, +) { let currentSenderGroup: SenderGroup = []; let currentDateGroup = { originalDate: messages[0].date, @@ -39,7 +41,7 @@ export function groupMessages(messages: ApiMessage[], firstUnreadId?: number, is }; } else { currentAlbum.messages.push(message); - if (message.content.text) { + if (message.hasComments || (message.content.text && !currentAlbum.mainMessage.hasComments)) { currentAlbum.mainMessage = message; } } @@ -56,6 +58,7 @@ export function groupMessages(messages: ApiMessage[], firstUnreadId?: number, is currentSenderGroup.push(currentAlbum); currentAlbum = undefined; } + const lastSenderGroupItem = currentSenderGroup[currentSenderGroup.length - 1]; if (nextMessage) { const nextMessageDayStartsAt = getDayStartAt(nextMessage.date * 1000); if (currentDateGroup.datetime !== nextMessageDayStartsAt) { @@ -77,6 +80,11 @@ export function groupMessages(messages: ApiMessage[], firstUnreadId?: number, is || message.inlineButtons || nextMessage.inlineButtons || (nextMessage.date - message.date) > GROUP_INTERVAL_SECONDS + || (topMessageId + && (message.id === topMessageId + || (lastSenderGroupItem + && 'mainMessage' in lastSenderGroupItem && lastSenderGroupItem.mainMessage?.id === topMessageId)) + && nextMessage.id !== topMessageId) || (isChatWithSelf && message.forwardInfo?.senderUserId !== nextMessage.forwardInfo?.senderUserId) ) { currentSenderGroup = []; diff --git a/src/components/middle/hooks/useScrollHooks.ts b/src/components/middle/hooks/useScrollHooks.ts index 3c75bb65f..c247fcee6 100644 --- a/src/components/middle/hooks/useScrollHooks.ts +++ b/src/components/middle/hooks/useScrollHooks.ts @@ -7,7 +7,6 @@ import type { Signal } from '../../../util/signals'; import { LoadMoreDirection } from '../../../types'; import { requestMeasure } from '../../../lib/fasterdom/fasterdom'; -import { isLocalMessageId } from '../../../global/helpers'; import { debounce } from '../../../util/schedulers'; import { MESSAGE_LIST_SENSITIVE_AREA } from '../../../util/windowEnvironment'; @@ -92,12 +91,6 @@ export default function useScrollHooks( return; } - // Loading history while sending a message can return the same message and cause ambiguity - const isFirstMessageLocal = isLocalMessageId(messageIds[0]); - if (isFirstMessageLocal) { - return; - } - entries.forEach(({ isIntersecting, target }) => { if (!isIntersecting) return; diff --git a/src/components/middle/message/CommentButton.scss b/src/components/middle/message/CommentButton.scss index 639ca7ba8..5e3153e85 100644 --- a/src/components/middle/message/CommentButton.scss +++ b/src/components/middle/message/CommentButton.scss @@ -20,6 +20,11 @@ transition: background-color 0.15s, color 0.15s; user-select: none; + .label { + overflow: hidden; + text-overflow: ellipsis; + } + .Message .has-appendix &::before { content: ""; display: block; @@ -45,7 +50,7 @@ bottom: 3rem; height: 3.375rem; border-radius: 1.375rem; - padding: 0.375rem 0.3125rem 0.25rem; + padding: 0.375rem; align-items: flex-start; color: white; background-color: var(--pattern-color); @@ -66,7 +71,7 @@ } } - .Message:hover & { + .Message:hover &, &.loading { opacity: 1; } @@ -97,7 +102,7 @@ .recent-repliers, .icon-comments, .label, - .icon-next { + .CommentButton_icon-open { display: none; } } @@ -153,8 +158,8 @@ margin-inline-end: 0.875rem; } - .icon-next { - margin-inline-start: auto; + .CommentButton_icon-open { + position: absolute; font-size: 1.5rem; } @@ -210,3 +215,34 @@ pointer-events: none; } } + +.CommentButton_loading, .CommentButton_icon-open, .CommentButton_icon-comments { + transition: transform 250ms ease-in-out, opacity 250ms ease-in-out; +} + +.CommentButton_icon-open { + right: 0; +} + +.CommentButton_loading { + position: absolute; + --spinner-size: 1.5rem; + flex-shrink: 0; + right: 0.5rem; + + .CommentButton-custom-shape & { + right: 0; + } +} + +.CommentButton_right { + position: relative; + margin-inline-start: auto; + height: 1.5rem; + width: 2.5rem; +} + +.CommentButton_hidden { + opacity: 0; + transform: scale(0.4); +} diff --git a/src/components/middle/message/CommentButton.tsx b/src/components/middle/message/CommentButton.tsx index b20fe63e2..b6602146c 100644 --- a/src/components/middle/message/CommentButton.tsx +++ b/src/components/middle/message/CommentButton.tsx @@ -2,9 +2,7 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useMemo } from '../../../lib/teact/teact'; import { getActions, getGlobal } from '../../../global'; -import type { - ApiThreadInfo, -} from '../../../api/types'; +import type { ApiCommentsInfo } from '../../../api/types'; import { selectPeer } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; @@ -12,30 +10,42 @@ import { formatIntegerCompact } from '../../../util/textFormat'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; +import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import AnimatedCounter from '../../common/AnimatedCounter'; import Avatar from '../../common/Avatar'; +import Spinner from '../../ui/Spinner'; import './CommentButton.scss'; type OwnProps = { - threadInfo: ApiThreadInfo; + threadInfo: ApiCommentsInfo; disabled?: boolean; + isLoading?: boolean; + isCustomShape?: boolean; }; +const SHOW_LOADER_DELAY = 450; + const CommentButton: FC = ({ + isCustomShape, threadInfo, disabled, + isLoading, }) => { - const { openComments } = getActions(); + const { openThread } = getActions(); + + const shouldRenderLoading = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); const lang = useLang(); const { - threadId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId, + originMessageId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId, } = threadInfo; const handleClick = useLastCallback(() => { - openComments({ id: chatId, threadId, originChannelId }); + openThread({ + isComments: true, chatId, originMessageId, originChannelId, + }); }); const recentRepliers = useMemo(() => { @@ -73,7 +83,7 @@ const CommentButton: FC = ({ const hasUnread = Boolean(lastReadInboxMessageId && lastMessageId && lastReadInboxMessageId < lastMessageId); - const commentsText = messagesCount ? (lang('Comments', '%COMMENTS_COUNT%', undefined, messagesCount) as string) + const commentsText = messagesCount ? (lang('CommentsCount', '%COMMENTS_COUNT%', undefined, messagesCount) as string) .split('%') .map((s) => { return (s === 'COMMENTS_COUNT' ? : s); @@ -83,17 +93,48 @@ const CommentButton: FC = ({ return (
- - {(!recentRepliers || recentRepliers.length === 0) && } + + {!recentRepliers?.length && } {renderRecentRepliers()}
{messagesCount ? commentsText : lang('LeaveAComment')}
- +
+ {isLoading && ( + + ) } + +
); }; diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 64a7346b6..8ff5a7aa6 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -166,7 +166,7 @@ const ContextMenuContainer: FC = ({ onCloseAnimationEnd, }) => { const { - openChat, + openThread, updateDraftReplyInfo, setEditingId, pinMessage, @@ -301,8 +301,8 @@ const ContextMenuContainer: FC = ({ }); const handleOpenThread = useLastCallback(() => { - openChat({ - id: message.chatId, + openThread({ + chatId: message.chatId, threadId: message.id, }); closeMenu(); diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 20989af2c..9642f747d 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -714,6 +714,10 @@ } } + .message-action-button-shown { + opacity: 1; + } + &.own .message-action-button { left: -3rem; } diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 9484db942..ebbe9c524 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -88,7 +88,6 @@ import { selectTabState, selectTheme, selectThreadInfo, - selectThreadTopMessageId, selectTopicFromMessage, selectUploadProgress, selectUser, @@ -271,6 +270,7 @@ type StateProps = { withStickerEffects?: boolean; webPageStory?: ApiTypeStory; isConnected: boolean; + isLoadingComments?: boolean; shouldWarnAboutSvg?: boolean; }; @@ -334,6 +334,7 @@ const Message: FC = ({ outgoingStatus, uploadProgress, isInDocumentGroup, + isLoadingComments, isProtected, isChatProtected, isFocused, @@ -663,7 +664,8 @@ const Message: FC = ({ && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments; - const withCommentButton = repliesThreadInfo && !isInDocumentGroupNotLast && messageListType === 'thread' + const withCommentButton = repliesThreadInfo?.isCommentsInfo + && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments; const withQuickReactionButton = !isTouchScreen && !phoneCall && !isInSelectMode && defaultReaction && !isInDocumentGroupNotLast && !isStoryMention; @@ -719,7 +721,7 @@ const Message: FC = ({ replyToMsgId, replyMessage, message.id, - isQuote || isReplyPrivate, + shouldHideReply || isQuote || isReplyPrivate, ); useEnsureStory( @@ -1370,7 +1372,9 @@ const Message: FC = ({ {!isInDocumentGroupNotLast && metaPosition === 'standalone' && !isStoryMention && renderReactionsAndMeta()} {canShowActionButton && canForward ? ( ) : canShowActionButton && canFocus ? ( ) : undefined} - {withCommentButton && } + {withCommentButton && ( + + )} {withAppendix && } {withQuickReactionButton && quickReactionPosition === 'in-content' && renderQuickReactionButton()} @@ -1430,13 +1443,14 @@ const Message: FC = ({ export default memo(withGlobal( (global, ownProps): StateProps => { const { - focusedMessage, forwardMessages, activeEmojiInteractions, activeReactions, + focusedMessage, forwardMessages, activeReactions, activeEmojiInteractions, + loadingThread, } = selectTabState(global); const { message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup, } = ownProps; const { - id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, repliesThreadInfo, + id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, } = message; const chat = selectChat(global, chatId); @@ -1460,16 +1474,13 @@ export default memo(withGlobal( ? chatFullInfo?.adminMembersById?.[sender?.id] : undefined; - const threadTopMessageId = threadId ? selectThreadTopMessageId(global, chatId, threadId) : undefined; - const isThreadTop = message.id === threadTopMessageId; + const isThreadTop = message.id === threadId; const { replyToMsgId, replyToPeerId, replyFrom } = getMessageReplyInfo(message) || {}; const { userId: storyReplyUserId, storyId: storyReplyId } = getStoryReplyInfo(message) || {}; - const shouldHideReply = replyToMsgId && replyToMsgId === threadTopMessageId; - const replyMessage = replyToMsgId && !shouldHideReply - ? selectChatMessage(global, replyToPeerId || chatId, replyToMsgId) - : undefined; + const shouldHideReply = replyToMsgId && replyToMsgId === threadId; + const replyMessage = replyToMsgId ? selectChatMessage(global, replyToPeerId || chatId, replyToMsgId) : undefined; const forwardHeader = forwardInfo || replyFrom; const replyMessageSender = replyMessage ? selectReplySender(global, replyMessage) : forwardHeader && !isRepliesChat ? selectSenderFromHeader(global, forwardHeader) : undefined; @@ -1509,9 +1520,8 @@ export default memo(withGlobal( const { canReply } = (messageListType === 'thread' && selectAllowedMessageActions(global, message, threadId)) || {}; const isDownloading = selectIsDownloading(global, message); - const actualRepliesThreadInfo = repliesThreadInfo - ? selectThreadInfo(global, repliesThreadInfo.chatId, repliesThreadInfo.threadId) || repliesThreadInfo - : undefined; + + const repliesThreadInfo = selectThreadInfo(global, chatId, album?.mainMessage.id || id); const isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum; const documentGroupFirstMessageId = isInDocumentGroup @@ -1584,7 +1594,7 @@ export default memo(withGlobal( canAutoPlayMedia: selectCanAutoPlayMedia(global, message), autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb, shouldLoopStickers: selectShouldLoopStickers(global), - repliesThreadInfo: actualRepliesThreadInfo, + repliesThreadInfo, availableReactions: global.availableReactions, defaultReaction: isMessageLocal(message) || messageListType === 'scheduled' ? undefined : selectDefaultReaction(global, chatId), @@ -1606,6 +1616,9 @@ export default memo(withGlobal( withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), webPageStory, isConnected, + isLoadingComments: repliesThreadInfo?.isCommentsInfo + && loadingThread?.loadingChatId === repliesThreadInfo?.originChannelId + && loadingThread?.loadingMessageId === repliesThreadInfo?.originMessageId, shouldWarnAboutSvg: global.settings.byKey.shouldWarnAboutSvg, ...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }), ...(typeof uploadProgress === 'number' && { uploadProgress }), diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index b7eb78dba..3cead036e 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -35,7 +35,7 @@ export default function useInnerHandlers( const { openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, markMessagesRead, cancelSendingMessage, sendPollVote, openForwardMenu, - openChatLanguageModal, openStoryViewer, focusMessageInComments, + openChatLanguageModal, openThread, openStoryViewer, } = getActions(); const { @@ -156,7 +156,7 @@ export default function useInnerHandlers( } if (replyToPeerId && replyToTopId) { - focusMessageInComments({ + focusMessage({ chatId: replyToPeerId, threadId: replyToTopId, messageId: forwardInfo!.fromMessageId!, @@ -181,8 +181,8 @@ export default function useInnerHandlers( }); const handleOpenThread = useLastCallback(() => { - openChat({ - id: message.chatId, + openThread({ + chatId: message.chatId, threadId: message.id, }); }); diff --git a/src/components/right/GifSearch.tsx b/src/components/right/GifSearch.tsx index 4909e8ae7..cf9de0f90 100644 --- a/src/components/right/GifSearch.tsx +++ b/src/components/right/GifSearch.tsx @@ -169,8 +169,8 @@ export default memo(withGlobal( const isChatWithBot = chat ? selectIsChatWithBot(global, chat) : undefined; const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId); const threadInfo = chatId && threadId ? selectThreadInfo(global, chatId, threadId) : undefined; - const isComments = Boolean(threadInfo?.originChannelId); - const canPostInChat = Boolean(chat) && Boolean(threadId) && getCanPostInChat(chat, threadId, isComments); + const isMessageThread = Boolean(!threadInfo?.isCommentsInfo && threadInfo?.fromChannelId); + const canPostInChat = Boolean(chat) && Boolean(threadId) && getCanPostInChat(chat, threadId, isMessageThread); return { query, diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index 58c5b740b..1a8bd8ad5 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -1,9 +1,10 @@ -import type { - ApiChat, ApiChatType, ApiContact, ApiInputMessageReplyInfo, ApiPeer, ApiUrlAuthResult, -} from '../../../api/types'; import type { InlineBotSettings } from '../../../types'; import type { RequiredGlobalActions } from '../../index'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; +import { + type ApiChat, type ApiChatType, type ApiContact, type ApiInputMessageReplyInfo, type ApiPeer, type ApiUrlAuthResult, + MAIN_THREAD_ID, +} from '../../../api/types'; import { GENERAL_REFETCH_INTERVAL } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; @@ -793,8 +794,8 @@ addActionHandler('callAttachBot', (global, actions, payload): ActionReturnType = } if ('chatId' in payload) { - const { chatId, threadId, url } = payload; - actions.openChat({ id: chatId, threadId, tabId }); + const { chatId, threadId = MAIN_THREAD_ID, url } = payload; + actions.openThread({ chatId, threadId, tabId }); actions.requestWebView({ url, peerId: chatId!, diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 15b3d819f..eeaf225c1 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -30,7 +30,7 @@ import { import { formatShareText, parseChooseParameter, processDeepLink } from '../../../util/deeplink'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getOrderedIds } from '../../../util/folderManager'; -import { buildCollectionByKey, omit } from '../../../util/iteratees'; +import { buildCollectionByKey, omit, pick } from '../../../util/iteratees'; import * as langProvider from '../../../util/langProvider'; import { debounce, pause, throttle } from '../../../util/schedulers'; import { extractCurrentThemeParams } from '../../../util/themeStyle'; @@ -70,6 +70,7 @@ import { updateListedTopicIds, updateManagementProgress, updatePeerFullInfo, + updateThread, updateThreadInfo, updateTopic, updateTopics, @@ -78,13 +79,24 @@ import { import { updateGroupCall } from '../../reducers/calls'; import { updateTabState } from '../../reducers/tabs'; import { - selectChat, selectChatByUsername, - selectChatFolder, selectChatFullInfo, selectChatListType, selectCurrentChat, selectCurrentMessageList, selectDraft, + selectChat, + selectChatByUsername, + selectChatFolder, + selectChatFullInfo, + selectChatListType, + selectCurrentChat, + selectCurrentMessageList, + selectDraft, selectIsChatPinned, selectLastServiceNotification, selectStickerSet, - selectSupportChat, selectTabState, selectThread, selectThreadInfo, selectThreadOriginChat, selectThreadTopMessageId, - selectUser, selectUserByPhoneNumber, selectVisibleUsers, + selectSupportChat, + selectTabState, + selectThread, + selectThreadInfo, + selectUser, + selectUserByPhoneNumber, + selectVisibleUsers, } from '../../selectors'; import { selectGroupCall } from '../../selectors/calls'; import { selectCurrentLimit } from '../../selectors/limits'; @@ -132,16 +144,19 @@ addActionHandler('preloadTopChatMessages', async (global, actions): Promise { - const { - id, threadId = MAIN_THREAD_ID, noRequestThreadInfoUpdate, tabId = getCurrentTabId(), - } = payload; +function abortChatRequests(chatId: string, threadId?: number) { + callApi('abortChatRequests', { chatId, threadId }); +} +function abortChatRequestsForCurrentChat( + global: T, newChatId?: string, newThreadId?: number, + ...[tabId = getCurrentTabId()]: TabArgs +) { const currentMessageList = selectCurrentMessageList(global, tabId); const currentChatId = currentMessageList?.chatId; const currentThreadId = currentMessageList?.threadId; - if (currentChatId && (currentChatId !== id || currentThreadId !== threadId)) { + if (currentChatId && (currentChatId !== newChatId || currentThreadId !== newThreadId)) { const [isChatOpened, isThreadOpened] = Object.values(global.byTabId) .reduce(([accHasChatOpened, accHasThreadOpened], { id: otherTabId }) => { if (otherTabId === tabId || (accHasChatOpened && accHasThreadOpened)) { @@ -153,14 +168,33 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => { const isSameThread = isSameChat && otherMessageList?.threadId === currentThreadId; return [accHasChatOpened || isSameChat, accHasThreadOpened || isSameThread]; - }, [currentChatId === id, false]); + }, [currentChatId === newChatId, false]); const shouldAbortChatRequests = !isChatOpened || !isThreadOpened; if (shouldAbortChatRequests) { - callApi('abortChatRequests', { chatId: currentChatId, threadId: isChatOpened ? currentThreadId : undefined }); + abortChatRequests(currentChatId, isChatOpened ? currentThreadId : undefined); } } +} + +addActionHandler('openChat', (global, actions, payload): ActionReturnType => { + const { + id, type, noForumTopicPanel, shouldReplaceHistory, shouldReplaceLast, + tabId = getCurrentTabId(), + } = payload; + + actions.processOpenChatOrThread({ + chatId: id, + type, + threadId: MAIN_THREAD_ID, + noForumTopicPanel, + shouldReplaceHistory, + shouldReplaceLast, + tabId, + }); + + abortChatRequestsForCurrentChat(global, id, MAIN_THREAD_ID, tabId); if (!id) { return; @@ -186,54 +220,227 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => { actions.requestChatUpdate({ chatId: id }); } actions.closeStoryViewer({ tabId }); - - if (threadId !== MAIN_THREAD_ID && !noRequestThreadInfoUpdate) { - actions.requestThreadInfoUpdate({ chatId: id, threadId }); - } }); -addActionHandler('openComments', async (global, actions, payload): Promise => { +addActionHandler('openThread', async (global, actions, payload): Promise => { const { - id, threadId, originChannelId, tabId = getCurrentTabId(), + type, isComments, noForumTopicPanel, shouldReplaceHistory, shouldReplaceLast, + focusMessageId, + tabId = getCurrentTabId(), } = payload; + let { chatId } = payload; + let threadId: number | undefined; + let loadingChatId: string; + let loadingThreadId: number; - if (threadId !== MAIN_THREAD_ID) { - const topMessageId = selectThreadTopMessageId(global, id, threadId); - if (!topMessageId) { - const chat = selectThreadOriginChat(global, id, threadId); - if (!chat) { - return; - } + if (!isComments) { + loadingChatId = payload.chatId; + threadId = payload.threadId; + loadingThreadId = threadId; + const originalChat = selectChat(global, loadingChatId); + if (threadId === MAIN_THREAD_ID) { actions.openChat({ - id, threadId, tabId, noRequestThreadInfoUpdate: true, + id: chatId, + type, + noForumTopicPanel, + shouldReplaceHistory, + shouldReplaceLast, + tabId, }); + return; + } else if (originalChat?.isForum) { + actions.processOpenChatOrThread({ + chatId, + type, + threadId, + isComments, + noForumTopicPanel, + shouldReplaceHistory, + shouldReplaceLast, + tabId, + }); + return; + } + } else { + const { originChannelId, originMessageId } = payload; - const result = await callApi('requestThreadInfoUpdate', { chat, threadId, originChannelId }); - if (!result) { - actions.openPreviousChat({ tabId }); - return; - } + loadingChatId = originChannelId; + loadingThreadId = originMessageId; + } + + const chat = selectChat(global, loadingChatId); + const threadInfo = selectThreadInfo(global, loadingChatId, loadingThreadId); + const thread = selectThread(global, loadingChatId, loadingThreadId); + if (!chat) return; + + abortChatRequestsForCurrentChat(global, loadingChatId, loadingThreadId, tabId); + + if (chatId + && threadInfo?.threadId + && (isComments || (thread?.listedIds?.length && thread.listedIds.includes(threadInfo.threadId)))) { + global = updateTabState(global, { + loadingThread: undefined, + }, tabId); + setGlobal(global); + actions.processOpenChatOrThread({ + chatId, + type, + threadId: threadInfo.threadId, + isComments, + noForumTopicPanel, + shouldReplaceHistory, + shouldReplaceLast, + tabId, + }); + return; + } + + let { loadingThread } = selectTabState(global, tabId); + if (loadingThread) { + abortChatRequests(loadingThread.loadingChatId, loadingThread.loadingMessageId); + } + + global = updateTabState(global, { + loadingThread: { + loadingChatId, + loadingMessageId: loadingThreadId, + }, + }, tabId); + setGlobal(global); + + const openPreviousChat = () => { + // eslint-disable-next-line eslint-multitab-tt/no-immediate-global + const currentGlobal = getGlobal(); + if (isComments + || selectCurrentMessageList(currentGlobal, tabId)?.chatId !== loadingChatId + || selectCurrentMessageList(currentGlobal, tabId)?.threadId !== loadingThreadId) { + return; + } + actions.openPreviousChat({ tabId }); + }; + + if (!isComments) { + actions.processOpenChatOrThread({ + chatId, + type, + threadId: threadId!, + tabId, + isComments, + noForumTopicPanel, + shouldReplaceHistory, + shouldReplaceLast, + }); + } + + const result = await callApi('fetchDiscussionMessage', { + chat: selectChat(global, loadingChatId)!, + messageId: loadingThreadId, + }); + + global = getGlobal(); + loadingThread = selectTabState(global, tabId).loadingThread; + if (loadingThread?.loadingChatId !== loadingChatId || loadingThread?.loadingMessageId !== loadingThreadId) { + openPreviousChat(); + return; + } + + if (!result) { + global = updateTabState(global, { + loadingThread: undefined, + }, tabId); + setGlobal(global); + + actions.showNotification({ + message: langProvider.translate(isComments ? 'ChannelPostDeleted' : 'lng_message_not_found'), + tabId, + }); + + openPreviousChat(); + return; + } + + threadId ??= result.threadId; + chatId ??= result.chatId; + + if (!chatId) { + openPreviousChat(); + return; + } + + global = getGlobal(); + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + global = addChats(global, buildCollectionByKey(result.chats, 'id')); + global = addMessages(global, result.messages); + if (isComments) { + global = updateThreadInfo(global, loadingChatId, loadingThreadId, { + threadId, + }); + + global = updateThreadInfo(global, chatId, threadId, { + isCommentsInfo: false, + threadId, + chatId, + fromChannelId: loadingChatId, + fromMessageId: loadingThreadId, + ...(threadInfo + && pick(threadInfo, ['messagesCount', 'lastMessageId', 'lastReadInboxMessageId', 'recentReplierIds'])), + }); + } + global = updateThread(global, chatId, threadId, { + firstMessageId: result.firstMessageId, + }); + setGlobal(global); + + if (focusMessageId) { + actions.focusMessage({ + chatId, + threadId: threadId!, + messageId: focusMessageId, + tabId, + }); + } + + actions.loadViewportMessages({ + chatId, + threadId, + tabId, + onError: () => { global = getGlobal(); - global = addUsers(global, buildCollectionByKey(result.users, 'id')); + global = updateTabState(global, { + loadingThread: undefined, + }, tabId); setGlobal(global); - actions.openChat({ - id, - threadId: result.topMessageId, + actions.showNotification({ + message: langProvider.translate('Group.ErrorAccessDenied'), tabId, - shouldReplaceLast: true, - noRequestThreadInfoUpdate: true, }); - } else { - actions.openChat({ - id, - threadId: topMessageId, + }, + onLoaded: () => { + global = getGlobal(); + loadingThread = selectTabState(global, tabId).loadingThread; + if (loadingThread?.loadingChatId !== loadingChatId || loadingThread?.loadingMessageId !== loadingThreadId) { + return; + } + + global = updateTabState(global, { + loadingThread: undefined, + }, tabId); + setGlobal(global); + + actions.processOpenChatOrThread({ + chatId, + type, + threadId: threadId!, tabId, - noRequestThreadInfoUpdate: true, + isComments, + noForumTopicPanel, + shouldReplaceHistory, + shouldReplaceLast, }); - } - } + }, + }); }); addActionHandler('openLinkedChat', async (global, actions, payload): Promise => { @@ -250,28 +457,6 @@ addActionHandler('openLinkedChat', async (global, actions, payload): Promise => { - const { - chatId, threadId, messageId, tabId = getCurrentTabId(), - } = payload!; - const chat = selectChat(global, chatId); - if (!chat) { - return; - } - - const result = await callApi('requestThreadInfoUpdate', { chat, threadId }); - if (!result) { - return; - } - global = getGlobal(); - global = addUsers(global, buildCollectionByKey(result.users, 'id')); - setGlobal(global); - - actions.focusMessage({ - chatId, threadId, messageId, tabId, - }); -}); - addActionHandler('openSupportChat', async (global, actions, payload): Promise => { const { tabId = getCurrentTabId() } = payload || {}; const chat = selectSupportChat(global); @@ -1227,20 +1412,16 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise } } - const { chatId, type } = selectCurrentMessageList(global, tabId) || {}; const usernameChat = selectChatByUsername(global, username); - if (chatId && commentId && messageId && usernameChat && type === 'thread') { - const threadInfo = selectThreadInfo(global, chatId, messageId); - - if (threadInfo && threadInfo.chatId === chatId) { - actions.focusMessage({ - chatId: threadInfo.chatId, - threadId: threadInfo.threadId, - messageId: commentId, - tabId, - }); - return; - } + if (commentId && messageId && usernameChat) { + actions.openThread({ + isComments: true, + originChannelId: usernameChat.id, + originMessageId: messageId, + tabId, + focusMessageId: commentId, + }); + return; } if (!isWebApp) actions.openChat({ id: TMP_CHAT_ID, tabId }); @@ -1249,8 +1430,6 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise if (!chatByUsername) return; - global = getGlobal(); - if (isWebApp && chatByUsername) { const theme = extractCurrentThemeParams(); @@ -1266,29 +1445,12 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise if (!messageId) return; - const threadInfo = selectThreadInfo(global, chatByUsername.id, messageId); - let discussionChatId: string | undefined; - - if (!threadInfo) { - const result = await callApi('requestThreadInfoUpdate', { chat: chatByUsername, threadId: messageId }); - if (!result) return; - - global = getGlobal(); - global = addUsers(global, buildCollectionByKey(result.users, 'id')); - setGlobal(global); - - discussionChatId = result.discussionChatId; - } else { - discussionChatId = threadInfo.chatId; - } - - if (!discussionChatId) return; - - actions.focusMessage({ - chatId: discussionChatId, - threadId: messageId, - messageId: Number(commentId), + actions.openThread({ + isComments: true, + originChannelId: chatByUsername.id, + originMessageId: messageId, tabId, + focusMessageId: commentId, }); }); @@ -1971,8 +2133,8 @@ addActionHandler('createTopic', async (global, actions, payload): Promise chat, title, iconColor, iconEmojiId, }); if (topicId) { - actions.openChat({ - id: chatId, threadId: topicId, shouldReplaceHistory: true, tabId, + actions.openThread({ + chatId, threadId: topicId, shouldReplaceHistory: true, tabId, }); } actions.closeCreateTopicPanel({ tabId }); @@ -2673,7 +2835,7 @@ async function openChatByUsername( chatId: chat.id, threadId, messageId: channelPostId, tabId, }); } else if (!isCurrentChat) { - actions.openChat({ id: chat.id, threadId, tabId }); + actions.openThread({ chatId: chat.id, threadId: threadId ?? MAIN_THREAD_ID, tabId }); } if (startParam) { diff --git a/src/global/actions/api/localSearch.ts b/src/global/actions/api/localSearch.ts index fdfc95da2..8abfe8df0 100644 --- a/src/global/actions/api/localSearch.ts +++ b/src/global/actions/api/localSearch.ts @@ -1,6 +1,6 @@ +import type { ApiChat } from '../../../api/types'; import type { SharedMediaType } from '../../../types'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; -import { type ApiChat, MAIN_THREAD_ID } from '../../../api/types'; import { MESSAGE_SEARCH_SLICE, SHARED_MEDIA_SLICE } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; @@ -21,7 +21,6 @@ import { selectCurrentMediaSearch, selectCurrentMessageList, selectCurrentTextSearch, - selectThreadInfo, } from '../../selectors'; addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Promise => { @@ -36,12 +35,6 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr const { query, results } = currentSearch; const offsetId = results?.nextOffsetId; - let topMessageId: number | undefined; - if (threadId !== MAIN_THREAD_ID) { - const threadInfo = selectThreadInfo(global, chatId!, threadId); - topMessageId = threadInfo?.topMessageId; - } - if (!query) { return; } @@ -50,7 +43,7 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr chat, type: 'text', query, - topMessageId, + threadId, limit: MESSAGE_SEARCH_SLICE, offsetId, }); @@ -147,7 +140,7 @@ async function searchSharedMedia( chat, type, limit: SHARED_MEDIA_SLICE * 2, - topMessageId: threadId === MAIN_THREAD_ID ? undefined : threadId, + threadId, offsetId, }); diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index cf0154b60..b9284d98a 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -36,7 +36,7 @@ import { import { ensureProtocol } from '../../../util/ensureProtocol'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { - areSortedArraysIntersecting, buildCollectionByKey, omit, split, unique, + areSortedArraysIntersecting, buildCollectionByKey, omit, partition, split, unique, } from '../../../util/iteratees'; import { translate } from '../../../util/langProvider'; import { debounce, onTickEnd, rafPromise } from '../../../util/schedulers'; @@ -44,8 +44,11 @@ import { IS_IOS } from '../../../util/windowEnvironment'; import { callApi, cancelApiProgress } from '../../../api/gramjs'; import { getMessageOriginalId, - getUserFullName, isChatChannel, - isDeletedUser, isMessageLocal, + getUserFullName, + isChatChannel, + isDeletedUser, + isLocalMessageId, + isMessageLocal, isServiceNotificationMessage, isUserBot, } from '../../helpers'; @@ -72,7 +75,6 @@ import { updateRequestedMessageTranslation, updateSponsoredMessage, updateThreadInfo, - updateThreadInfos, updateThreadUnreadFromForwardedMessage, updateTopic, } from '../../reducers'; @@ -107,8 +109,6 @@ import { selectSponsoredMessage, selectTabState, selectThreadIdFromMessage, - selectThreadOriginChat, - selectThreadTopMessageId, selectTranslationLanguage, selectUser, selectUserFullInfo, @@ -127,6 +127,8 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur direction = LoadMoreDirection.Around, isBudgetPreload = false, shouldForceRender = false, + onLoaded, + onError, tabId = getCurrentTabId(), } = payload || {}; @@ -135,6 +137,7 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur if (!chatId || !threadId) { const currentMessageList = selectCurrentMessageList(global, tabId); if (!currentMessageList) { + onError?.(); return; } @@ -145,6 +148,7 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur const chat = selectChat(global, chatId); // TODO Revise if `chat.isRestricted` check is needed if (!chat || chat.isRestricted) { + onError?.(); return; } @@ -168,12 +172,18 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur if (!areAllLocal) { onTickEnd(() => { void loadViewportMessages( - global, chat, threadId!, offsetId, LoadMoreDirection.Around, isOutlying, isBudgetPreload, tabId, + global, chat, threadId!, offsetId, LoadMoreDirection.Around, isOutlying, isBudgetPreload, onLoaded, tabId, ); }); + } else { + onLoaded?.(); } } else { const offsetId = direction === LoadMoreDirection.Backwards ? viewportIds[0] : viewportIds[viewportIds.length - 1]; + + // Prevent requests with local offsets + if (isLocalMessageId(offsetId)) return; + const isOutlying = Boolean(listedIds && !listedIds.includes(offsetId)); const historyIds = (isOutlying ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : listedIds)!; @@ -187,7 +197,17 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur onTickEnd(() => { void loadWithBudget( - global, actions, areAllLocal, isOutlying, isBudgetPreload, chat, threadId!, direction, offsetId, tabId, + global, + actions, + areAllLocal, + isOutlying, + isBudgetPreload, + chat, + threadId!, + direction, + offsetId, + onLoaded, + tabId, ); }); @@ -204,17 +224,18 @@ async function loadWithBudget( actions: RequiredGlobalActions, areAllLocal: boolean, isOutlying: boolean, isBudgetPreload: boolean, chat: ApiChat, threadId: number, direction: LoadMoreDirection, offsetId?: number, + onLoaded?: NoneToVoidFunction, ...[tabId = getCurrentTabId()]: TabArgs ) { if (!areAllLocal) { await loadViewportMessages( - global, chat, threadId, offsetId, direction, isOutlying, isBudgetPreload, tabId, + global, chat, threadId, offsetId, direction, isOutlying, isBudgetPreload, onLoaded, tabId, ); } if (!isBudgetPreload) { actions.loadViewportMessages({ - chatId: chat.id, threadId, direction, isBudgetPreload: true, tabId, + chatId: chat.id, threadId, direction, isBudgetPreload: true, onLoaded, tabId, }); } } @@ -571,8 +592,7 @@ addActionHandler('unpinAllMessages', async (global, actions, payload): Promise a - b) .map((id) => selectChatMessage(global, fromChatId, id)).filter(Boolean) : undefined; - if (!fromChat || !toChat || !messages || (toThreadId && !toChat.isForum)) { + if (!fromChat || !toChat || !messages || (toThreadId && !isToMainThread && !toChat.isForum)) { return; } const sendAs = selectSendAs(global, toChatId!); - const realMessages = messages.filter((m) => !isServiceNotificationMessage(m)); + const [realMessages, serviceMessages] = partition(messages, (m) => !isServiceNotificationMessage(m)); if (realMessages.length) { (async () => { await rafPromise(); // Wait one frame for any previous `sendMessage` to be processed @@ -923,8 +950,7 @@ addActionHandler('forwardMessages', (global, actions, payload): ActionReturnType })(); } - messages - .filter((m) => isServiceNotificationMessage(m)) + serviceMessages .forEach((message) => { const { text, entities } = message.content.text || {}; const { sticker, poll } = message.content; @@ -1022,22 +1048,6 @@ addActionHandler('rescheduleMessage', (global, actions, payload): ActionReturnTy }); }); -addActionHandler('requestThreadInfoUpdate', async (global, actions, payload): Promise => { - const { chatId, threadId } = payload; - const chat = selectChat(global, chatId); - if (!chat) { - return; - } - - const originChannelId = selectThreadOriginChat(global, chatId, threadId)?.id; - - const result = await callApi('requestThreadInfoUpdate', { chat, threadId, originChannelId }); - if (!result) return; - global = getGlobal(); - global = addUsers(global, buildCollectionByKey(result.users, 'id')); - setGlobal(global); -}); - addActionHandler('transcribeAudio', async (global, actions, payload): Promise => { const { messageId, chatId } = payload; @@ -1093,6 +1103,7 @@ async function loadViewportMessages( direction: LoadMoreDirection, isOutlying = false, isBudgetPreload = false, + onLoaded?: NoneToVoidFunction, ...[tabId = getCurrentTabId()]: TabArgs ) { const chatId = chat.id; @@ -1133,7 +1144,7 @@ async function loadViewportMessages( } const { - messages, users, chats, repliesThreadInfos, + messages, users, chats, } = result; global = getGlobal(); @@ -1146,7 +1157,7 @@ async function loadViewportMessages( const ids = Object.keys(byId).map(Number); if (threadId !== MAIN_THREAD_ID) { - const threadFirstMessageId = selectFirstMessageId(global, chatId, threadId) || {}; + const threadFirstMessageId = selectFirstMessageId(global, chatId, threadId); if ((!ids[0] || threadFirstMessageId === ids[0]) && threadFirstMessageId !== threadId) { ids.unshift(threadId); } @@ -1159,7 +1170,6 @@ async function loadViewportMessages( global = addUsers(global, buildCollectionByKey(users, 'id')); global = addChats(global, buildCollectionByKey(chats, 'id')); - global = updateThreadInfos(global, repliesThreadInfos); let listedIds = selectListedIds(global, chatId, threadId); const outlyingList = offsetId ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : undefined; @@ -1174,12 +1184,15 @@ async function loadViewportMessages( } if (!isBudgetPreload) { - const historyIds = isOutlying ? outlyingList! : listedIds!; - const { newViewportIds } = getViewportSlice(historyIds, offsetId, direction); - global = safeReplaceViewportIds(global, chatId, threadId, newViewportIds!, tabId); + const historyIds = isOutlying && outlyingList ? outlyingList : listedIds; + if (historyIds) { + const { newViewportIds } = getViewportSlice(historyIds, offsetId, direction); + global = safeReplaceViewportIds(global, chatId, threadId, newViewportIds!, tabId); + } } setGlobal(global); + onLoaded?.(); } async function loadMessage( @@ -1570,7 +1583,7 @@ addActionHandler('setForwardChatOrTopic', async (global, actions, payload): Prom }, tabId); setGlobal(global); - actions.openChat({ id: chatId, threadId: topicId, tabId }); + actions.openThread({ chatId, threadId: topicId || MAIN_THREAD_ID, tabId }); actions.closeMediaViewer({ tabId }); actions.exitMessageSelectMode({ tabId }); }); @@ -1732,19 +1745,7 @@ addActionHandler('loadMessageViews', async (global, actions, payload): Promise(global: T, actions: acc[chatId] = Object .keys(global.messages.byChatId[chatId].threadsById) .reduce>>((acc2, threadId) => { - acc2[Number(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; }, {}); @@ -123,11 +125,21 @@ async function loadAndReplaceMessages(global: T, actions: global = getGlobal(); const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {}; const activeThreadId = currentThreadId || MAIN_THREAD_ID; - const threadInfo = currentThreadId && currentChatId + const threadInfo = currentChatId && currentThreadId ? selectThreadInfo(global, currentChatId, currentThreadId) : undefined; const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined; if (currentChatId && currentChat) { - const result = await loadTopMessages(currentChat, activeThreadId, threadInfo?.lastReadInboxMessageId); + const [result, resultDiscussion] = await Promise.all([ + loadTopMessages( + currentChat, + activeThreadId, + activeThreadId !== MAIN_THREAD_ID ? activeThreadId : undefined, + ), + activeThreadId !== MAIN_THREAD_ID ? callApi('fetchDiscussionMessage', { + chat: currentChat, + messageId: activeThreadId, + }) : undefined, + ]); global = getGlobal(); const { chatId: newCurrentChatId } = selectCurrentMessageList(global, tabId) || {}; @@ -142,10 +154,12 @@ async function loadAndReplaceMessages(global: T, actions: .filter(Boolean) : []; - const allMessages = ([] as ApiMessage[]).concat(result.messages, localMessages); + const isDiscussionStartLoaded = result.messages.some(({ id }) => id === resultDiscussion?.firstMessageId); + const threadStartMessages = (isDiscussionStartLoaded && resultDiscussion?.topMessages) || []; + const allMessages = threadStartMessages.concat(result.messages, localMessages); const allMessagesWithTopicLastMessages = allMessages.concat(topicLastMessages); const byId = buildCollectionByKey(allMessagesWithTopicLastMessages, 'id'); - const listedIds = allMessages.map(({ id }) => id); + const listedIds = unique(allMessages.map(({ id }) => id)); if (!wasReset) { global = { @@ -166,8 +180,16 @@ async function loadAndReplaceMessages(global: T, actions: global = addChatMessagesById(global, currentChatId, byId); global = updateListedIds(global, currentChatId, activeThreadId, listedIds); - if (threadInfo?.originChannelId) { - global = updateThreadInfo(global, currentChatId, activeThreadId, threadInfo); + if (resultDiscussion) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + resultDiscussion.threadInfoUpdates.forEach((update) => { + global = updateThreadInfo(global, currentChatId, activeThreadId, update); + }); + } + if (threadInfo && !threadInfo.isCommentsInfo && activeThreadId !== MAIN_THREAD_ID) { + global = updateThreadInfo(global, currentChatId, activeThreadId, { + ...pick(threadInfo, ['fromChannelId', 'fromMessageId']), + }); } // eslint-disable-next-line @typescript-eslint/no-loop-func Object.values(global.byTabId).forEach(({ id: otherTabId }) => { @@ -178,9 +200,6 @@ async function loadAndReplaceMessages(global: T, actions: }); global = updateChats(global, buildCollectionByKey(result.chats, 'id')); global = updateUsers(global, buildCollectionByKey(result.users, 'id')); - if (result.repliesThreadInfos.length) { - global = updateThreadInfos(global, result.repliesThreadInfos); - } areMessagesLoaded = true; } @@ -235,11 +254,11 @@ async function loadAndReplaceMessages(global: T, actions: }); } -function loadTopMessages(chat: ApiChat, threadId: number, lastReadInboxId?: number) { +function loadTopMessages(chat: ApiChat, threadId: number, offsetId?: number) { return callApi('fetchMessages', { chat, threadId, - offsetId: lastReadInboxId || chat.lastReadInboxMessageId, + offsetId: offsetId || chat.lastReadInboxMessageId, addOffset: -(Math.round(MESSAGE_LIST_SLICE / 2) + 1), limit: MESSAGE_LIST_SLICE, }); diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index b16318f29..f68f96cdb 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -1,5 +1,5 @@ import type { - ApiChat, ApiMessage, ApiPollResult, ApiReactions, ApiThreadInfo, + ApiChat, ApiMessage, ApiPollResult, ApiReactions, } from '../../../api/types'; import type { RequiredGlobalActions } from '../../index'; import type { @@ -33,6 +33,7 @@ import { updateMessageTranslations, updateScheduledMessage, updateThreadInfo, + updateThreadInfos, updateThreadUnreadFromForwardedMessage, updateTopic, } from '../../reducers'; @@ -75,15 +76,6 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = updateWithLocalMedia(global, chatId, id, message); global = updateListedAndViewportIds(global, actions, message as ApiMessage); - if (message.repliesThreadInfo) { - global = updateThreadInfo( - global, - message.repliesThreadInfo.chatId, - message.repliesThreadInfo.threadId, - message.repliesThreadInfo, - ); - } - const newMessage = selectChatMessage(global, chatId, id)!; const replyInfo = getMessageReplyInfo(newMessage); const storyReplyInfo = getStoryReplyInfo(newMessage); @@ -114,11 +106,6 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } } - const { threadInfo } = selectThreadByMessage(global, message as ApiMessage) || {}; - if (threadInfo && !isLocal) { - actions.requestThreadInfoUpdate({ chatId, threadId: threadInfo.threadId }); - } - // @perf Wait until scroll animation finishes or simply rely on delivery status update // (which is itself delayed) if (!isLocal) { @@ -204,14 +191,6 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = updateWithLocalMedia(global, chatId, id, message); const newMessage = selectChatMessage(global, chatId, id)!; - if (message.repliesThreadInfo) { - global = updateThreadInfo( - global, - message.repliesThreadInfo.chatId, - message.repliesThreadInfo.threadId, - message.repliesThreadInfo, - ); - } if (currentMessage) { global = updateChatLastMessage(global, chatId, newMessage); @@ -293,7 +272,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.markMessageListRead({ maxId: message.id, tabId }); }); - if (thread?.threadInfo) { + if (thread?.threadInfo?.threadId) { global = replaceThreadParam(global, chatId, thread.threadInfo.threadId, 'threadInfo', { ...thread.threadInfo, lastMessageId: message.id, @@ -364,43 +343,33 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { break; } - case 'updateThreadInfo': { + case 'updateThreadInfos': { const { - chatId, threadId, threadInfo, firstMessageId, + threadInfoUpdates, } = update; - const currentThreadInfo = selectThreadInfo(global, chatId, threadId); - const newThreadInfo = { - ...currentThreadInfo, - ...threadInfo, - }; + global = updateThreadInfos(global, threadInfoUpdates); + threadInfoUpdates.forEach((threadInfo) => { + const { chatId, threadId } = threadInfo; + if (!chatId || !threadId) return; - if (!newThreadInfo.threadId) { - return; - } - - global = updateThreadInfo(global, chatId, threadId, newThreadInfo as ApiThreadInfo); - - if (firstMessageId) { - global = replaceThreadParam(global, chatId, threadId, 'firstMessageId', firstMessageId); - } - - const chat = selectChat(global, chatId); - if (chat?.isForum && threadInfo.lastReadInboxMessageId !== currentThreadInfo?.lastReadInboxMessageId) { - actions.loadTopicById({ chatId, topicId: threadId }); - } - - // Update reply thread last read message id if already read in main thread - if (threadInfo.topMessageId === threadId && !chat?.isForum) { - const lastReadInboxMessageId = chat?.lastReadInboxMessageId; - const lastReadInboxMessageIdInThread = newThreadInfo.lastReadInboxMessageId || lastReadInboxMessageId; - if (lastReadInboxMessageId && lastReadInboxMessageIdInThread) { - global = updateThreadInfo(global, chatId, threadId, { - lastReadInboxMessageId: Math.max(lastReadInboxMessageIdInThread, lastReadInboxMessageId), - }); + const chat = selectChat(global, chatId); + const currentThreadInfo = selectThreadInfo(global, chatId, threadId); + if (chat?.isForum && threadInfo.lastReadInboxMessageId !== currentThreadInfo?.lastReadInboxMessageId) { + actions.loadTopicById({ chatId, topicId: threadId }); } - } + // 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), + }); + } + } + }); setGlobal(global); break; @@ -807,35 +776,37 @@ function updateListedAndViewportIds( ) { const { id, chatId } = message; - const { threadInfo, firstMessageId } = selectThreadByMessage(global, message) || {}; + 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 ?? {}; - if (threadInfo) { - if (firstMessageId || !isMessageLocal(message)) { - global = updateListedIds(global, chatId, threadInfo.threadId, [id]); + if (threadInfo && threadId) { + global = updateListedIds(global, chatId, threadId, [id]); - Object.values(global.byTabId).forEach(({ id: tabId }) => { - if (selectIsViewportNewest(global, chatId, threadInfo.threadId, tabId)) { - global = addViewportId(global, chatId, threadInfo.threadId, id, tabId); + Object.values(global.byTabId).forEach(({ id: tabId }) => { + if (selectIsViewportNewest(global, chatId, threadId, tabId)) { + // Always keep the first unread message in the viewport list + const firstUnreadId = selectFirstUnreadId(global, chatId, threadId); + const candidateGlobal = addViewportId(global, chatId, threadId, id, tabId); + const newViewportIds = selectViewportIds(candidateGlobal, chatId, threadId, tabId); - if (!firstMessageId) { - global = replaceThreadParam(global, chatId, threadInfo.threadId, 'firstMessageId', message.id); - } + if (!firstUnreadId || newViewportIds!.includes(firstUnreadId)) { + global = candidateGlobal; } - }); - } + } + }); - global = replaceThreadParam(global, chatId, threadInfo.threadId, 'threadInfo', { + global = replaceThreadParam(global, chatId, threadId, 'threadInfo', { ...threadInfo, lastMessageId: message.id, }); if (!isMessageLocal(message) && !isActionMessage(message)) { - global = updateThreadInfo(global, chatId, threadInfo.threadId, { + global = updateThreadInfo(global, chatId, threadId, { messagesCount: (threadInfo.messagesCount || 0) + 1, }); } @@ -895,9 +866,9 @@ function updateChatLastMessage( return global; } -function findLastMessage(global: T, chatId: string) { +function findLastMessage(global: T, chatId: string, threadId = MAIN_THREAD_ID) { const byId = selectChatMessages(global, chatId); - const listedIds = selectListedIds(global, chatId, MAIN_THREAD_ID); + const listedIds = selectListedIds(global, chatId, threadId); if (!byId || !listedIds) { return undefined; @@ -906,7 +877,7 @@ function findLastMessage(global: T, chatId: string) { let i = listedIds.length; while (i--) { const message = byId[listedIds[i]]; - if (!message.isDeleting) { + if (message && !message.isDeleting) { return message; } } @@ -923,6 +894,9 @@ export function deleteMessages( const chat = selectChat(global, chatId); if (!chat) return; + const threadIdsToUpdate = new Set(); + threadIdsToUpdate.add(MAIN_THREAD_ID); + ids.forEach((id) => { global = updateChatMessage(global, chatId, id, { isDeleting: true, @@ -930,21 +904,10 @@ export function deleteMessages( global = clearMessageTranslation(global, chatId, id); - const newLastMessage = findLastMessage(global, chatId); - if (newLastMessage) { - global = updateChatLastMessage(global, chatId, newLastMessage, true); - } - if (chat.topics?.[id]) { global = deleteTopic(global, chatId, id); } - }); - actions.requestChatUpdate({ chatId }); - - const threadIdsToUpdate: number[] = []; - - ids.forEach((id) => { const message = selectChatMessage(global, chatId, id); if (!message) { return; @@ -954,20 +917,36 @@ export function deleteMessages( const threadId = selectThreadIdFromMessage(global, message); if (threadId) { - threadIdsToUpdate.push(threadId); + threadIdsToUpdate.add(threadId); } }); + actions.requestChatUpdate({ chatId }); + + const idsSet = new Set(ids); + + threadIdsToUpdate.forEach((threadId) => { + const threadInfo = selectThreadInfo(global, chatId, threadId); + if (!threadInfo?.lastMessageId || !idsSet.has(threadInfo.lastMessageId)) return; + + const newLastMessage = findLastMessage(global, chatId, threadId); + if (!newLastMessage) return; + + if (threadId === MAIN_THREAD_ID) { + global = updateChatLastMessage(global, chatId, newLastMessage, true); + } + + global = updateThreadInfo(global, chatId, threadId, { + lastMessageId: newLastMessage.id, + }); + }); + setGlobal(global); setTimeout(() => { global = getGlobal(); global = deleteChatMessages(global, chatId, ids); setGlobal(global); - - unique(threadIdsToUpdate).forEach((threadId) => { - actions.requestThreadInfoUpdate({ chatId, threadId }); - }); }, ANIMATION_DELAY); return; diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index ab38c5d4f..b24ce4b9a 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -14,9 +14,9 @@ import { } from '../../selectors'; import { closeLocalTextSearch } from './localSearch'; -addActionHandler('openChat', (global, actions, payload): ActionReturnType => { +addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionReturnType => { const { - id, + chatId, threadId = MAIN_THREAD_ID, type = 'thread', shouldReplaceHistory = false, @@ -38,12 +38,12 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => { } if (!currentMessageList || ( - currentMessageList.chatId !== id + currentMessageList.chatId !== chatId || currentMessageList.threadId !== threadId || currentMessageList.type !== type )) { - if (id) { - global = replaceTabThreadParam(global, id, threadId, 'replyStack', [], tabId); + if (chatId) { + global = replaceTabThreadParam(global, chatId, threadId, 'replyStack', [], tabId); global = updateTabState(global, { activeReactions: {}, @@ -57,25 +57,25 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => { isStatisticsShown: false, boostStatistics: undefined, contentToBeScheduled: undefined, - ...(id !== selectTabState(global, tabId).forwardMessages.toChatId && { + ...(chatId !== selectTabState(global, tabId).forwardMessages.toChatId && { forwardMessages: {}, }), }, tabId); } - if (id) { - const chat = selectChat(global, id); + if (chatId) { + const chat = selectChat(global, chatId); if (chat?.isForum && !noForumTopicPanel) { - actions.openForumPanel({ chatId: id!, tabId }); - } else if (id !== selectTabState(global, tabId).forumPanelChatId) { + actions.openForumPanel({ chatId, tabId }); + } else if (chatId !== selectTabState(global, tabId).forumPanelChatId) { actions.closeForumPanel({ tabId }); } } actions.updatePageTitle({ tabId }); - return updateCurrentMessageList(global, id, threadId, type, shouldReplaceHistory, shouldReplaceLast, tabId); + return updateCurrentMessageList(global, chatId, threadId, type, shouldReplaceHistory, shouldReplaceLast, tabId); }); addActionHandler('openChatInNewTab', (global, actions, payload): ActionReturnType => { @@ -110,13 +110,26 @@ addActionHandler('openChatWithInfo', (global, actions, payload): ActionReturnTyp actions.openChat({ ...payload, tabId }); }); +addActionHandler('openThreadWithInfo', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload; + + global = updateTabState(global, { + ...selectTabState(global, tabId), + isChatInfoShown: true, + }, tabId); + global = { ...global, lastIsChatInfoShown: true }; + setGlobal(global); + + actions.openThread({ ...payload, tabId }); +}); + addActionHandler('openChatWithDraft', (global, actions, payload): ActionReturnType => { const { - chatId, text, threadId, files, filter, tabId = getCurrentTabId(), + chatId, text, threadId = MAIN_THREAD_ID, files, filter, tabId = getCurrentTabId(), } = payload; if (chatId) { - actions.openChat({ id: chatId, threadId, tabId }); + actions.openThread({ chatId, threadId, tabId }); } return updateTabState(global, { diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 2559e2a07..ee220888d 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -440,8 +440,8 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => const viewportIds = selectViewportIds(global, chatId, threadId, tabId); if (viewportIds && viewportIds.includes(messageId)) { setGlobal(global, { forceOnHeavyAnimation: true }); - actions.openChat({ - id: chatId, + actions.openThread({ + chatId, threadId, type: messageListType, shouldReplaceHistory, @@ -462,8 +462,8 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => setGlobal(global, { forceOnHeavyAnimation: true }); - actions.openChat({ - id: chatId, + actions.openThread({ + chatId, threadId, type: messageListType, shouldReplaceHistory, @@ -471,6 +471,8 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => tabId, }); actions.loadViewportMessages({ + chatId, + threadId, tabId, shouldForceRender: true, }); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 2891d0e53..620f47c10 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -2,11 +2,9 @@ import { addCallback } from '../../../lib/teact/teactn'; import type { ApiError, ApiNotification } from '../../../api/types'; import type { ActionReturnType, GlobalState } from '../../types'; -import { MAIN_THREAD_ID } from '../../../api/types'; import { - DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT, INACTIVE_MARKER, - PAGE_TITLE, + DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT, INACTIVE_MARKER, PAGE_TITLE, } from '../../../config'; import { getAllMultitabTokens, getCurrentTabId, reestablishMasterToSelf } from '../../../util/establishMultitabRole'; import { getAllNotificationsCount } from '../../../util/folderManager'; @@ -134,7 +132,7 @@ addActionHandler('closeManagement', (global, actions, payload): ActionReturnType }, tabId); }); -addActionHandler('openChat', (global, actions, payload): ActionReturnType => { +addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload; if (!getIsMobile() && !getIsTablet()) { return undefined; @@ -541,7 +539,7 @@ addActionHandler('openCreateTopicPanel', (global, actions, payload): ActionRetur // Topic panel can be opened only if there is a selected chat const currentChat = selectCurrentChat(global, tabId); - if (!currentChat) actions.openChat({ id: chatId, threadId: MAIN_THREAD_ID, tabId }); + if (!currentChat) actions.openChat({ id: chatId, tabId }); return updateTabState(global, { createTopicPanel: { diff --git a/src/global/actions/ui/reactions.ts b/src/global/actions/ui/reactions.ts index 70fb5c44f..a851b36ed 100644 --- a/src/global/actions/ui/reactions.ts +++ b/src/global/actions/ui/reactions.ts @@ -5,16 +5,16 @@ import { addActionHandler } from '../../index'; import { updateTabState } from '../../reducers/tabs'; import { selectTabState } from '../../selectors'; -addActionHandler('openChat', (global, actions, payload): ActionReturnType => { +addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionReturnType => { const { - id, + chatId, tabId = getCurrentTabId(), } = payload; - if (id) { + if (chatId) { return updateTabState(global, { reactionPicker: { - chatId: id, + chatId, messageId: undefined, position: undefined, }, diff --git a/src/global/cache.ts b/src/global/cache.ts index 96418dd7f..613c7c4a4 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -33,7 +33,6 @@ import { selectChat, selectChatMessages, selectCurrentMessageList, - selectThreadOriginChat, selectViewportIds, selectVisibleUsers, } from './selectors'; @@ -335,17 +334,8 @@ function reduceChats(global: T): GlobalState['chats'] { const { chats: { byId }, currentUserId } = global; const currentChatIds = compact( Object.values(global.byTabId) - .flatMap(({ id: tabId }): MessageList[] | undefined => { - const messageList = selectCurrentMessageList(global, tabId); - if (!messageList) return undefined; - - const { chatId, threadId } = messageList; - const origin = selectThreadOriginChat(global, chatId, threadId); - return origin ? [{ - chatId: origin.id, - threadId: MAIN_THREAD_ID, - type: 'thread', - }, messageList] : [messageList]; + .map(({ id: tabId }): MessageList | undefined => { + return selectCurrentMessageList(global, tabId); }), ).map(({ chatId }) => chatId); @@ -407,14 +397,17 @@ function reduceMessages(global: T): GlobalState['messages const chat = selectChat(global, chatId); - const threadIds = compact(Object.values(global.byTabId).map(({ id: tabId }) => { + const threadIds = unique(compact(Object.values(global.byTabId).map(({ id: tabId }) => { const { chatId: tabChatId, threadId } = selectCurrentMessageList(global, tabId) || {}; if (!tabChatId || tabChatId !== chatId || !threadId || threadId === MAIN_THREAD_ID) { return undefined; } return threadId; - })); + }).concat( + Object.values(global.messages.byChatId[chatId].threadsById || {}) + .map(({ threadInfo }) => (threadInfo?.isCommentsInfo ? threadInfo?.originMessageId : undefined)), + ))); const threadIdsToSave = threadIds.length ? [MAIN_THREAD_ID, ...threadIds] : [MAIN_THREAD_ID]; const threadsToSave = pickTruthy(current.threadsById, threadIdsToSave); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index 98335e8f6..0c7b1a8de 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -142,7 +142,7 @@ export function isUserRightBanned(chat: ApiChat, key: keyof ApiChatBannedRights) ); } -export function getCanPostInChat(chat: ApiChat, threadId: number, isComments?: boolean) { +export function getCanPostInChat(chat: ApiChat, threadId: number, isMessageThread?: boolean) { if (threadId !== MAIN_THREAD_ID) { if (chat.isForum) { if (chat.isNotJoined) { @@ -157,7 +157,7 @@ export function getCanPostInChat(chat: ApiChat, threadId: number, isComments?: b } if (chat.isRestricted || chat.isForbidden || chat.migratedTo - || (!isComments && chat.isNotJoined) || isChatWithRepliesBot(chat.id)) { + || (!isMessageThread && chat.isNotJoined) || isChatWithRepliesBot(chat.id)) { return false; } diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index 936ff123f..14f254ccd 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -14,7 +14,7 @@ import { } from '../../config'; import { getCurrentTabId } from '../../util/establishMultitabRole'; import { - areSortedArraysEqual, omit, pickTruthy, unique, + areSortedArraysEqual, excludeSortedArray, omit, pick, pickTruthy, unique, } from '../../util/iteratees'; import { isLocalMessageId, mergeIdRanges, orderHistoryIds, orderPinnedIds, @@ -31,7 +31,7 @@ import { selectOutlyingLists, selectPinnedIds, selectScheduledIds, - selectTabState, selectThreadInfo, + selectTabState, selectThreadIdFromMessage, selectThreadInfo, selectViewportIds, } from '../selectors'; import { updateTabState } from './tabs'; @@ -100,17 +100,16 @@ export function updateTabThread( } export function updateThread( - global: T, chatId: string, threadId: number, threadUpdate: Partial, + global: T, chatId: string, threadId: number, threadUpdate: Partial | undefined, ): T { - const current = global.messages.byChatId[chatId]; - - if (threadUpdate.listedIds?.length) { - const lastListedId = threadUpdate.listedIds[threadUpdate.listedIds.length - 1]; - if (lastListedId) { - global = updateTopicLastMessageId(global, chatId, threadId, lastListedId); - } + 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), @@ -243,40 +242,49 @@ export function deleteChatMessages( if (!byId) { return global; } - const newById = omit(byId, messageIds); + + orderHistoryIds(messageIds); + const updatedThreads = new Map(); + updatedThreads.set(MAIN_THREAD_ID, messageIds); + + messageIds.forEach((messageId) => { + const message = byId[messageId]; + if (!message) return; + const threadId = selectThreadIdFromMessage(global, message); + if (!threadId || threadId === MAIN_THREAD_ID) return; + const threadMessages = updatedThreads.get(threadId) || []; + threadMessages.push(messageId); + updatedThreads.set(threadId, threadMessages); + }); + const deletedForwardedPosts = Object.values(pickTruthy(byId, messageIds)).filter( ({ forwardInfo }) => forwardInfo?.isLinkedChannelPost, ); - const threadIds = Object.keys(global.messages.byChatId[chatId].threadsById).map(Number); - threadIds.forEach((threadId) => { + updatedThreads.forEach((threadMessageIds, threadId) => { const threadInfo = selectThreadInfo(global, chatId, threadId); let listedIds = selectListedIds(global, chatId, threadId); let pinnedIds = selectPinnedIds(global, chatId, threadId); let outlyingLists = selectOutlyingLists(global, chatId, threadId); - let mainPinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID); let newMessageCount = threadInfo?.messagesCount; - messageIds.forEach((messageId) => { - if (listedIds?.includes(messageId)) { - listedIds = listedIds.filter((id) => id !== messageId); - if (newMessageCount !== undefined && !isLocalMessageId(messageId)) newMessageCount -= 1; - } + if (listedIds) { + listedIds = excludeSortedArray(listedIds, threadMessageIds); + } - outlyingLists = outlyingLists?.map((list) => { - if (!list.includes(messageId)) return list; - return list.filter((id) => id !== messageId); - }); + if (outlyingLists) { + outlyingLists = outlyingLists.map((list) => excludeSortedArray(list, threadMessageIds)); + } - if (pinnedIds?.includes(messageId)) { - pinnedIds = pinnedIds.filter((id) => id !== messageId); - } + if (pinnedIds) { + pinnedIds = excludeSortedArray(pinnedIds, orderPinnedIds(threadMessageIds)); + } - if (mainPinnedIds?.includes(messageId)) { - mainPinnedIds = mainPinnedIds.filter((id) => id !== messageId); - } - }); + const nonLocalMessageCount = threadMessageIds.filter((id) => !isLocalMessageId(id)).length; + if (newMessageCount !== undefined) { + newMessageCount -= nonLocalMessageCount; + } Object.values(global.byTabId).forEach(({ id: tabId }) => { let viewportIds = selectViewportIds(global, chatId, threadId, tabId); @@ -293,7 +301,6 @@ export function deleteChatMessages( global = replaceThreadParam(global, chatId, threadId, 'listedIds', listedIds); global = replaceThreadParam(global, chatId, threadId, 'outlyingLists', outlyingLists); global = replaceThreadParam(global, chatId, threadId, 'pinnedIds', pinnedIds); - global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds', mainPinnedIds); if (threadInfo && newMessageCount !== undefined) { global = updateThreadInfo(global, chatId, threadId, { @@ -313,16 +320,17 @@ export function deleteChatMessages( const { fromChatId, fromMessageId } = message.forwardInfo!; const originalPost = selectChatMessage(global, fromChatId!, fromMessageId!); - if (canDeleteCurrentThread && currentThreadId === fromMessageId) { + if (canDeleteCurrentThread && currentThreadId === message.id) { global = updateCurrentMessageList(global, chatId, undefined, undefined, undefined, undefined, tabId); } if (originalPost) { - global = updateChatMessage(global, fromChatId!, fromMessageId!, { repliesThreadInfo: undefined }); + global = updateThread(global, fromChatId!, fromMessageId!, undefined); } }); }); } + const newById = omit(byId, messageIds); global = replaceChatMessages(global, chatId, newById); return global; @@ -477,14 +485,26 @@ export function safeReplacePinnedIds( export function updateThreadInfo( global: T, chatId: string, threadId: number, update: Partial | undefined, + doNotUpdateLinked?: boolean, ): T { const newThreadInfo = { ...(selectThreadInfo(global, chatId, threadId) as ApiThreadInfo), ...update, - }; + } as ApiThreadInfo; - if (!newThreadInfo.threadId) { - return global; + if (!doNotUpdateLinked) { + const linkedUpdate = pick(newThreadInfo, ['messagesCount', 'lastMessageId', 'lastReadInboxMessageId']); + if (newThreadInfo.isCommentsInfo) { + if (newThreadInfo.threadId) { + global = updateThreadInfo( + global, newThreadInfo.chatId, newThreadInfo.threadId, linkedUpdate, true, + ); + } + } else if (newThreadInfo.fromChannelId && newThreadInfo.fromMessageId) { + global = updateThreadInfo( + global, newThreadInfo.fromChannelId, newThreadInfo.fromMessageId, linkedUpdate, true, + ); + } } return replaceThreadParam(global, chatId, threadId, 'threadInfo', newThreadInfo); @@ -494,7 +514,10 @@ export function updateThreadInfos( global: T, updates: Partial[], ): T { updates.forEach((update) => { - global = updateThreadInfo(global, update.chatId!, update.threadId!, update); + global = updateThreadInfo(global, + update.isCommentsInfo ? update.originChannelId! : update.chatId!, + update.isCommentsInfo ? update.originMessageId! : update.threadId!, + update); }); return global; diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 8fdbba0f2..5fd9fa503 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -248,34 +248,6 @@ export function selectThreadMessagesCount(global: GlobalState, chatId: string, t return threadInfo.messagesCount; } -export function selectThreadOriginChat(global: T, chatId: string, threadId: number) { - if (threadId === MAIN_THREAD_ID) { - return selectChat(global, chatId); - } - - const threadInfo = selectThreadInfo(global, chatId, threadId); - - return selectChat(global, threadInfo?.originChannelId || chatId); -} - -export function selectThreadTopMessageId(global: T, chatId: string, threadId: number) { - if (threadId === MAIN_THREAD_ID) { - return undefined; - } - - const chat = selectChat(global, chatId); - if (chat?.isForum) { - return threadId; - } - - const threadInfo = selectThreadInfo(global, chatId, threadId); - if (!threadInfo) { - return undefined; - } - - return threadInfo.topMessageId; -} - export function selectThreadByMessage(global: T, message: ApiMessage) { const threadId = selectThreadIdFromMessage(global, message); if (!threadId || threadId === MAIN_THREAD_ID) { @@ -325,10 +297,12 @@ export function selectIsViewportNewest( } else { const threadInfo = selectThreadInfo(global, chatId, threadId); if (!threadInfo || !threadInfo.lastMessageId) { - return undefined; + if (!threadInfo?.threadId) return undefined; + // No messages in thread, except for the thread message itself + lastMessageId = threadInfo?.threadId; + } else { + lastMessageId = threadInfo.lastMessageId; } - - lastMessageId = threadInfo.lastMessageId; } // Edge case: outgoing `lastMessage` is updated with a delay to optimize animation @@ -580,9 +554,9 @@ export function selectAllowedMessageActions(global: T, me ); const threadInfo = selectThreadInfo(global, message.chatId, threadId); - const isComments = Boolean(threadInfo?.originChannelId); + const isMessageThread = Boolean(!threadInfo?.isCommentsInfo && threadInfo?.fromChannelId); const canReply = !isLocal && !isServiceNotification && !chat.isForbidden - && getCanPostInChat(chat, threadId, isComments) + && getCanPostInChat(chat, threadId, isMessageThread) && (!messageTopic || !messageTopic.isClosed || messageTopic.isOwner || getHasAdminRight(chat, 'manageTopics')); const hasPinPermission = isPrivate || ( @@ -799,7 +773,7 @@ export function selectRealLastReadId(global: T, chatId: s } if (!threadInfo.lastReadInboxMessageId) { - return threadInfo.topMessageId; + return threadInfo.threadId; } // Some previously read messages may be deleted @@ -977,9 +951,15 @@ export function selectNewestMessageWithBotKeyboardButtons return undefined; } - const messageId = findLast(viewportIds, (id) => selectShouldDisplayReplyKeyboard(global, chatMessages[id])); + const messageId = findLast(viewportIds, (id) => { + const message = chatMessages[id]; + return message && selectShouldDisplayReplyKeyboard(global, message); + }); - const replyHideMessageId = findLast(viewportIds, (id) => selectShouldHideReplyKeyboard(global, chatMessages[id])); + const replyHideMessageId = findLast(viewportIds, (id) => { + const message = chatMessages[id]; + return message && selectShouldHideReplyKeyboard(global, message); + }); if (messageId && replyHideMessageId && replyHideMessageId > messageId) { return undefined; @@ -1391,20 +1371,18 @@ export function selectTopicLink( } export function selectMessageReplyInfo( - global: T, chatId: string, threadId: number = MAIN_THREAD_ID, additionalReplyInfo?: ApiInputMessageReplyInfo, + global: T, chatId: string, threadId: number, additionalReplyInfo?: ApiInputMessageReplyInfo, ) { const chat = selectChat(global, chatId); if (!chat) return undefined; - - const replyingToTopId = selectThreadTopMessageId(global, chatId, threadId); - - if (!additionalReplyInfo && !replyingToTopId) return undefined; + const isMainThread = threadId === MAIN_THREAD_ID; + if (!additionalReplyInfo && isMainThread) return undefined; const replyInfo: ApiInputMessageReplyInfo = { type: 'message', ...additionalReplyInfo, - replyToMsgId: additionalReplyInfo?.replyToMsgId || replyingToTopId!, - replyToTopId: additionalReplyInfo?.replyToTopId || replyingToTopId, + replyToMsgId: additionalReplyInfo?.replyToMsgId || threadId, + replyToTopId: additionalReplyInfo?.replyToTopId || (!isMainThread ? threadId : undefined), }; return replyInfo; diff --git a/src/global/types.ts b/src/global/types.ts index ae10b2cd7..45fc5db66 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -400,6 +400,11 @@ export type TabState = { webPagePreview?: ApiWebPage; + loadingThread?: { + loadingChatId: string; + loadingMessageId: number; + }; + forwardMessages: { isModalShown?: boolean; fromChatId?: string; @@ -1176,6 +1181,7 @@ export interface ActionPayloads { shouldReplace?: boolean; }; openChatWithInfo: ActionPayloads['openChat'] & { profileTab?: ProfileTabType } & WithTabId; + openThreadWithInfo: ActionPayloads['openThread'] & WithTabId; openLinkedChat: { id: string } & WithTabId; loadMoreMembers: WithTabId | undefined; setActiveChatFolder: { @@ -1211,11 +1217,6 @@ export interface ActionPayloads { id: number; }; openSupportChat: WithTabId | undefined; - focusMessageInComments: { - chatId: string; - threadId: number; - messageId: number; - } & WithTabId; openChatByPhoneNumber: { phoneNumber: string; startAttach?: string | boolean; @@ -1267,6 +1268,8 @@ export interface ActionPayloads { chatId?: string; threadId?: number; shouldForceRender?: boolean; + onLoaded?: NoneToVoidFunction; + onError?: NoneToVoidFunction; } & WithTabId; sendMessage: { text?: string; @@ -1400,10 +1403,6 @@ export interface ActionPayloads { usernameOrId: string; isPrivate?: boolean; } & WithTabId; - requestThreadInfoUpdate: { - chatId: string; - threadId: number; - }; setScrollOffset: { chatId: string; threadId: number; @@ -1769,17 +1768,36 @@ export interface ActionPayloads { openChat: { id: string | undefined; - threadId?: number; type?: MessageListType; shouldReplaceHistory?: boolean; shouldReplaceLast?: boolean; noForumTopicPanel?: boolean; - noRequestThreadInfoUpdate?: boolean; } & WithTabId; - openComments: { - id: string; + openThread: { + type?: MessageListType; + shouldReplaceHistory?: boolean; + shouldReplaceLast?: boolean; + noForumTopicPanel?: boolean; + focusMessageId?: number; + } & ({ + isComments: true; + chatId?: string; + originMessageId: number; + originChannelId: string; + } | { + isComments?: false; + chatId: string; threadId: number; - originChannelId?: string; + }) & WithTabId; + // Used by both openThread & openChat + processOpenChatOrThread: { + chatId: string | undefined; + threadId: number; + type?: MessageListType; + shouldReplaceHistory?: boolean; + shouldReplaceLast?: boolean; + noForumTopicPanel?: boolean; + isComments?: boolean; } & WithTabId; loadFullChat: { chatId: string; diff --git a/src/util/iteratees.ts b/src/util/iteratees.ts index 0438ba23e..b63fd5620 100644 --- a/src/util/iteratees.ts +++ b/src/util/iteratees.ts @@ -63,6 +63,16 @@ export function omit(object: T, keys: K[]): return pick(object, savedKeys); } +export function omitUndefined(object: T): T { + return Object.keys(object).reduce((result, stringKey) => { + const key = stringKey as keyof T; + if (object[key] !== undefined) { + result[key as keyof T] = object[key]; + } + return result; + }, {} as T); +} + export function orderBy( collection: T[], orderRule: (keyof T) | OrderCallback | ((keyof T) | OrderCallback)[], @@ -119,6 +129,29 @@ export function areSortedArraysIntersecting(array1: any[], array2: any[]) { export function findIntersectionWithSet(array: T[], set: Set): T[] { return array.filter((a) => set.has(a)); } +/** + * Exlude elements from base array. Both arrays should be sorted in same order + * @param base + * @param toExclude + * @returns New array without excluded elements + */ +export function excludeSortedArray(base: T[], toExclude: T[]) { + if (!base?.length) return base; + + const result: T[] = []; + + let excludeIndex = 0; + + for (let i = 0; i < base.length; i++) { + if (toExclude[excludeIndex] === base[i]) { + excludeIndex += 1; + } else { + result.push(base[i]); + } + } + + return result; +} export function split(array: T[], chunkSize: number) { const result: T[][] = []; diff --git a/src/util/multitab.ts b/src/util/multitab.ts index 4acae63cf..2290746ee 100644 --- a/src/util/multitab.ts +++ b/src/util/multitab.ts @@ -7,7 +7,7 @@ import type { MethodArgs, Methods } from '../api/gramjs/methods/types'; import type { ApiInitialArgs } from '../api/types'; import type { GlobalState } from '../global/types'; -import { DATA_BROADCAST_CHANNEL_NAME, DEBUG, MULTITAB_LOCALSTORAGE_KEY } from '../config'; +import { DATA_BROADCAST_CHANNEL_NAME, MULTITAB_LOCALSTORAGE_KEY } from '../config'; import { selectTabState } from '../global/selectors'; import { callApiLocal,