Thread Message: Add comments button for document group (#6779)

This commit is contained in:
Alexander Zinchuk 2026-03-31 11:29:21 +02:00
parent 6f29b81ae7
commit a44ecb0113
5 changed files with 186 additions and 75 deletions

View File

@ -3,7 +3,7 @@ import { getIsHeavyAnimating, memo } from '../../lib/teact/teact';
import { getActions, getGlobal } from '../../global';
import type { ApiMessage } from '../../api/types';
import type { IAlbum, MessageListType, ThreadId } from '../../types';
import type { IAlbum, IDocumentGroup, MessageListType, ThreadId } from '../../types';
import type { Signal } from '../../util/signals';
import type { MessageDateGroup } from './helpers/groupMessages';
import type { OnIntersectPinnedMessage } from './hooks/usePinnedMessage';
@ -26,7 +26,7 @@ import { formatHumanDate, formatScheduledDateTime } from '../../util/dates/oldDa
import { convertTonFromNanos } from '../../util/formatCurrency';
import { compact } from '../../util/iteratees';
import { formatStarsAsText, formatTonAsText } from '../../util/localization/format';
import { isAlbum } from './helpers/groupMessages';
import { isAlbum, isDocumentGroup } from './helpers/groupMessages';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
import { renderPeerLink } from './message/helpers/messageActions';
@ -130,6 +130,7 @@ const MessageListContent = ({
const areDatesClickable = !isSavedDialog && !isSchedule;
const shouldRenderSponsoredMessage = canShowAds && isViewportNewest;
const shouldHideComments = hasLinkedChat === false || !isChannelChat || Boolean(isChatMonoforum);
const {
observeIntersectionForReading,
@ -270,7 +271,9 @@ const MessageListContent = ({
};
const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => {
return acc + messageGroup.senderGroups.flat().length;
return acc + messageGroup.senderGroups.flat().reduce((innerAcc, messageOrAlbum) => {
return innerAcc + (isDocumentGroup(messageOrAlbum) ? messageOrAlbum.messages.length : 1);
}, 0);
}, 0);
let appearanceIndex = 0;
@ -290,6 +293,7 @@ const MessageListContent = ({
if (
senderGroup.length === 1
&& !isAlbum(senderGroup[0])
&& !isDocumentGroup(senderGroup[0])
&& isActionMessage(senderGroup[0])
&& senderGroup[0].content.action?.type !== 'phoneCall'
) {
@ -318,31 +322,115 @@ const MessageListContent = ({
]);
}
let currentDocumentGroupId: string | undefined;
let currentDocumentGroupHasThreadTop = false;
const senderGroupElements = senderGroup.map((
messageOrAlbum,
messageIndex,
) => {
function renderMessageElement(
message: ApiMessage,
position: {
isFirstInGroup: boolean;
isLastInGroup: boolean;
isFirstInDocumentGroup: boolean;
isLastInDocumentGroup: boolean;
isLastInList: boolean;
},
isThreadTopMessage: boolean,
album?: IAlbum,
documentGroup?: IDocumentGroup,
) {
const isOwn = isOwnMessage(message);
const originalId = getMessageOriginalId(message);
const key = isServiceNotificationMessage(message)
? `${message.date}_${originalId}` : originalId;
return compact([
message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider,
message.paidMessageStars && !withUsers && renderPaidMessageAction(message, album),
message.suggestedPostInfo && renderSuggestedPostInfoAction(message),
<Message
key={key}
message={message}
observeIntersectionForBottom={observeIntersectionForReading}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
album={album}
documentGroup={documentGroup}
noAvatars={noAvatars}
withAvatar={position.isLastInGroup && withUsers && !isOwn && (!isThreadTopMessage || !isComments)}
withSenderName={position.isFirstInGroup && withUsers && !isOwn}
threadId={threadId}
messageListType={type}
noComments={shouldHideComments}
noReplies={!shouldHideComments || threadId !== MAIN_THREAD_ID || type === 'scheduled'}
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isJustAdded={position.isLastInList && isNewMessage}
isThreadTop={isThreadTopMessage}
isFirstInGroup={position.isFirstInGroup}
isLastInGroup={position.isLastInGroup}
isFirstInDocumentGroup={position.isFirstInDocumentGroup}
isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
getIsMessageListReady={getIsReady}
onMessageUnmount={onMessageUnmount}
/>,
]);
}
if (isDocumentGroup(messageOrAlbum)) {
const documentGroup = messageOrAlbum;
return documentGroup.messages.map((docMessage, docIndex) => {
const isFirstInDocGroup = docIndex === 0;
const isLastInDocGroup = docIndex === documentGroup.messages.length - 1;
if (docMessage.previousLocalId && anchorIdRef.current === getMessageHtmlId(docMessage.previousLocalId)) {
anchorIdRef.current = getMessageHtmlId(docMessage.id);
}
if (isFirstInDocGroup && docMessage.id === threadId) {
currentDocumentGroupHasThreadTop = true;
}
const isThreadTopMessage = docMessage.id === threadId || currentDocumentGroupHasThreadTop;
if (isLastInDocGroup) {
currentDocumentGroupHasThreadTop = false;
}
const position = {
isFirstInGroup: messageIndex === 0 && isFirstInDocGroup,
isLastInGroup: messageIndex === senderGroup.length - 1 && isLastInDocGroup,
isFirstInDocumentGroup: isFirstInDocGroup,
isLastInDocumentGroup: isLastInDocGroup,
isLastInList: (
messageIndex === senderGroup.length - 1
&& isLastInDocGroup
&& senderGroupIndex === senderGroupsArray.length - 1
&& dateGroupIndex === dateGroupsArray.length - 1
),
};
return renderMessageElement(docMessage, position, isThreadTopMessage, undefined, documentGroup);
}).flat();
}
const message = isAlbum(messageOrAlbum) ? messageOrAlbum.mainMessage : messageOrAlbum;
const album = isAlbum(messageOrAlbum) ? messageOrAlbum : undefined;
const isOwn = isOwnMessage(message);
const isMessageAlbum = isAlbum(messageOrAlbum);
const nextMessage = senderGroup[messageIndex + 1];
if (message.previousLocalId && anchorIdRef.current === getMessageHtmlId(message.previousLocalId)) {
anchorIdRef.current = getMessageHtmlId(message.id);
}
const documentGroupId = !isMessageAlbum && message.groupedId ? message.groupedId : undefined;
const nextDocumentGroupId = nextMessage && !isAlbum(nextMessage) ? nextMessage.groupedId : undefined;
const isThreadTopMessage = message.id === threadId;
const position = {
isFirstInGroup: messageIndex === 0,
isLastInGroup: messageIndex === senderGroup.length - 1,
isFirstInDocumentGroup: Boolean(documentGroupId && documentGroupId !== currentDocumentGroupId),
isLastInDocumentGroup: Boolean(documentGroupId && documentGroupId !== nextDocumentGroupId),
isFirstInDocumentGroup: false,
isLastInDocumentGroup: false,
isLastInList: (
messageIndex === senderGroup.length - 1
&& senderGroupIndex === senderGroupsArray.length - 1
@ -350,60 +438,33 @@ const MessageListContent = ({
),
};
currentDocumentGroupId = documentGroupId;
const originalId = getMessageOriginalId(message);
// Service notifications saved in cache in previous versions may share the same `previousLocalId`
const key = isServiceNotificationMessage(message) ? `${message.date}_${originalId}` : originalId;
const noComments = hasLinkedChat === false || !isChannelChat || Boolean(isChatMonoforum);
return compact([
message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider,
message.paidMessageStars && !withUsers && renderPaidMessageAction(message, album),
message.suggestedPostInfo && renderSuggestedPostInfoAction(message),
<Message
key={key}
message={message}
observeIntersectionForBottom={observeIntersectionForReading}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
album={album}
noAvatars={noAvatars}
withAvatar={position.isLastInGroup && withUsers && !isOwn && (!isThreadTopMessage || !isComments)}
withSenderName={position.isFirstInGroup && withUsers && !isOwn}
threadId={threadId}
messageListType={type}
noComments={noComments}
noReplies={!noComments || threadId !== MAIN_THREAD_ID || type === 'scheduled'}
appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isJustAdded={position.isLastInList && isNewMessage}
isFirstInGroup={position.isFirstInGroup}
isLastInGroup={position.isLastInGroup}
isFirstInDocumentGroup={position.isFirstInDocumentGroup}
isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
getIsMessageListReady={getIsReady}
onMessageUnmount={onMessageUnmount}
/>,
]);
return renderMessageElement(message, position, isThreadTopMessage, album);
}).flat();
if (!withUsers) return senderGroupElements;
const lastMessageOrAlbum = senderGroup[senderGroup.length - 1];
const lastMessage = isAlbum(lastMessageOrAlbum) ? lastMessageOrAlbum.mainMessage : lastMessageOrAlbum;
const lastItem = senderGroup[senderGroup.length - 1];
const lastMessage = isAlbum(lastItem)
? lastItem.mainMessage
: isDocumentGroup(lastItem)
? lastItem.messages[lastItem.messages.length - 1]
: lastItem;
const lastMessageId = getMessageOriginalId(lastMessage);
const lastAppearanceOrder = messageCountToAnimate - appearanceIndex;
const isThreadTopMessage = lastMessage.id === threadId;
const isOwn = isOwnMessage(lastMessage);
const firstMessageOrAlbum = senderGroup[0];
const firstMessage = isAlbum(firstMessageOrAlbum) ? firstMessageOrAlbum.mainMessage : firstMessageOrAlbum;
const firstItem = senderGroup[0];
const firstMessage = isAlbum(firstItem)
? firstItem.mainMessage
: isDocumentGroup(firstItem)
? firstItem.messages[0]
: firstItem;
const firstMessageId = getMessageOriginalId(firstMessage);
const isThreadTopMessage = lastMessage.id === threadId
|| (firstMessage.id === threadId && Boolean(firstMessage.groupedId));
const key = `${firstMessageId}-${lastMessageId}`;
const id = (firstMessageId === lastMessageId) ? `message-group-${firstMessageId}`
: `message-group-${firstMessageId}-${lastMessageId}`;

View File

@ -1,10 +1,10 @@
import type { ApiMessage } from '../../../api/types';
import type { IAlbum } from '../../../types';
import type { IAlbum, IDocumentGroup } from '../../../types';
import { isActionMessage } from '../../../global/helpers';
import { getDayStartAt } from '../../../util/dates/oldDateFormat';
type SenderGroup = (ApiMessage | IAlbum)[];
type SenderGroup = (ApiMessage | IAlbum | IDocumentGroup)[];
const GROUP_INTERVAL_SECONDS = 600; // 10 minutes
@ -14,10 +14,16 @@ export type MessageDateGroup = {
senderGroups: SenderGroup[];
};
export function isAlbum(messageOrAlbum: ApiMessage | IAlbum): messageOrAlbum is IAlbum {
export function isAlbum(messageOrAlbum: ApiMessage | IAlbum | IDocumentGroup): messageOrAlbum is IAlbum {
return 'albumId' in messageOrAlbum;
}
export function isDocumentGroup(
messageOrAlbum: ApiMessage | IAlbum | IDocumentGroup,
): messageOrAlbum is IDocumentGroup {
return 'documentGroupId' in messageOrAlbum;
}
export function groupMessages(
messages: ApiMessage[], firstUnreadId?: number, topMessageId?: number, isChatWithSelf?: boolean, withUsers?: boolean,
) {
@ -27,6 +33,7 @@ export function groupMessages(
senderGroups: [[]],
};
let currentAlbum: IAlbum | undefined;
let currentDocumentGroup: IDocumentGroup | undefined;
const dateGroups: MessageDateGroup[] = [initDateGroup];
@ -40,6 +47,8 @@ export function groupMessages(
messages: [message],
mainMessage: message,
hasMultipleCaptions: false,
commentsMessage: message.hasComments ? message : undefined,
captionMessage: message.content.text ? message : undefined,
} satisfies IAlbum;
} else {
currentAlbum.messages.push(message);
@ -63,6 +72,20 @@ export function groupMessages(
hasMultipleCaptions: false,
isPaidMedia: true,
} satisfies IAlbum);
} else if (message.groupedId) {
if (!currentDocumentGroup) {
currentDocumentGroup = {
documentGroupId: message.groupedId,
messages: [message],
firstMessageId: message.id,
commentsMessage: message.hasComments ? message : undefined,
} satisfies IDocumentGroup;
} else {
currentDocumentGroup.messages.push(message);
if (message.hasComments) {
currentDocumentGroup.commentsMessage = message;
}
}
} else {
currentSenderGroup.push(message);
}
@ -77,8 +100,16 @@ export function groupMessages(
currentAlbum = undefined;
}
if (
currentDocumentGroup
&& (!nextMessage || !nextMessage.groupedId || nextMessage.groupedId !== currentDocumentGroup.documentGroupId)
) {
currentSenderGroup.push(currentDocumentGroup);
currentDocumentGroup = undefined;
}
const lastMessageInSenderGroup = currentSenderGroup[currentSenderGroup.length - 1];
if (nextMessage && !currentAlbum) {
if (nextMessage && !currentAlbum && !currentDocumentGroup) {
const nextMessageDayStartsAt = getDayStartAt(nextMessage.date * 1000);
if (currentDateGroup.datetime !== nextMessageDayStartsAt) {
const newDateGroup: MessageDateGroup = {
@ -101,10 +132,12 @@ export function groupMessages(
|| (nextMessage.date - message.date) > GROUP_INTERVAL_SECONDS
|| (topMessageId
&& (message.id === topMessageId
|| (lastMessageInSenderGroup
&& 'mainMessage' in lastMessageInSenderGroup
&& lastMessageInSenderGroup.mainMessage?.id === topMessageId))
&& nextMessage.id !== topMessageId)
|| (lastMessageInSenderGroup && (
(isAlbum(lastMessageInSenderGroup) && lastMessageInSenderGroup.mainMessage.id === topMessageId)
|| (isDocumentGroup(lastMessageInSenderGroup) && lastMessageInSenderGroup.firstMessageId === topMessageId)
)))
&& nextMessage.id !== topMessageId
&& !(message.groupedId && message.groupedId === nextMessage.groupedId))
|| (isChatWithSelf && message.forwardInfo?.fromId !== nextMessage.forwardInfo?.fromId)
) {
currentDateGroup.senderGroups.push([]);

View File

@ -34,6 +34,7 @@ import type {
ChatTranslatedMessages,
FocusDirection,
IAlbum,
IDocumentGroup,
MessageListType,
ScrollTargetPosition,
TextSummary,
@ -97,7 +98,6 @@ import {
selectIsMessageFocused,
selectIsMessageProtected,
selectIsMessageSelected,
selectMessageIdsByGroupId,
selectMessageSummary,
selectOutgoingStatus,
selectPeer,
@ -225,6 +225,7 @@ type MessagePositionProperties = {
type OwnProps = {
message: ApiMessage;
album?: IAlbum;
documentGroup?: IDocumentGroup;
noAvatars?: boolean;
withAvatar?: boolean;
withSenderName?: boolean;
@ -234,6 +235,7 @@ type OwnProps = {
noReplies: boolean;
appearanceOrder: number;
isJustAdded: boolean;
isThreadTop?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined };
getIsMessageListReady?: Signal<boolean>;
observeIntersectionForBottom?: ObserveFn;
@ -249,7 +251,6 @@ type StateProps = {
canShowSender: boolean;
originSender?: ApiPeer;
botSender?: ApiUser;
isThreadTop?: boolean;
shouldHideReply?: boolean;
replyMessage?: ApiMessage;
replyMessageSender?: ApiPeer;
@ -2004,7 +2005,8 @@ export default memo(withGlobal<OwnProps>(
loadingThread,
} = selectTabState(global);
const {
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup,
message, album, documentGroup, withSenderName, withAvatar, threadId, messageListType,
isLastInDocumentGroup, isFirstInGroup,
} = ownProps;
const {
id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, viaBusinessBotId, effectId,
@ -2040,8 +2042,6 @@ export default memo(withGlobal<OwnProps>(
? (adminMembersById?.[sender?.id] || members?.find((member) => member.userId === sender?.id))
: undefined;
const isThreadTop = message.id === threadId;
const { replyToMsgId, replyToPeerId, replyFrom } = getMessageReplyInfo(message) || {};
const { peerId: storyReplyPeerId, storyId: storyReplyId } = getStoryReplyInfo(message) || {};
@ -2094,14 +2094,15 @@ export default memo(withGlobal<OwnProps>(
const downloadableMedia = selectMessageDownloadableMedia(global, message);
const isDownloading = downloadableMedia && getIsDownloading(activeDownloads, downloadableMedia);
const repliesThreadInfo = selectThreadInfo(global, chatId, album?.commentsMessage?.id || id);
const isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum;
const documentGroupFirstMessageId = isInDocumentGroup
? selectMessageIdsByGroupId(global, chatId, message.groupedId!)![0]
: undefined;
const repliesThreadInfo = selectThreadInfo(
global, chatId, album?.commentsMessage?.id || documentGroup?.commentsMessage?.id || id,
);
const reactionMessage = isInDocumentGroup ? (
isLastInDocumentGroup ? selectChatMessage(global, chatId, documentGroupFirstMessageId!) : undefined
isLastInDocumentGroup && documentGroup?.firstMessageId
? selectChatMessage(global, chatId, documentGroup.firstMessageId)
: undefined
) : message;
const readState = selectThreadReadState(global, chatId, threadId);
@ -2158,7 +2159,6 @@ export default memo(withGlobal<OwnProps>(
originSender,
botSender,
shouldHideReply: shouldHideReply || isReplyToTopicStart,
isThreadTop,
replyMessage,
replyMessageSender,
replyMessageForwardSender,

View File

@ -141,6 +141,7 @@ import {
selectIsMonoforumAdmin,
selectLanguageCode,
selectListedIds,
selectMessageIdsByGroupId,
selectMessageReplyInfo,
selectOutlyingListByMessageId,
selectPeer,
@ -1809,7 +1810,16 @@ async function loadViewportMessages<T extends GlobalState>(
if (threadId !== MAIN_THREAD_ID && !getIsSavedDialog(chatId, threadId, global.currentUserId)) {
const threadFirstMessageId = selectFirstMessageId(global, chatId, threadId);
if ((!ids[0] || threadFirstMessageId === ids[0]) && threadFirstMessageId !== threadId) {
ids.unshift(Number(threadId));
const threadTopMessage = selectChatMessage(global, chatId, Number(threadId));
const groupedIds = threadTopMessage?.groupedId
? selectMessageIdsByGroupId(global, chatId, threadTopMessage.groupedId)
: undefined;
if (groupedIds && groupedIds.length > 1) {
ids.unshift(...groupedIds);
} else {
ids.unshift(Number(threadId));
}
}
}

View File

@ -96,6 +96,13 @@ export interface IAlbum {
commentsMessage?: ApiMessage;
}
export interface IDocumentGroup {
documentGroupId: string;
messages: ApiMessage[];
firstMessageId: number;
commentsMessage?: ApiMessage;
}
export type ThreadId = string | number;
export type ThemeKey = 'light' | 'dark';