Introduce Message Threads (#2546)

This commit is contained in:
Alexander Zinchuk 2023-02-28 18:43:11 +01:00
parent 6d98d69e17
commit 962d3e321f
34 changed files with 511 additions and 276 deletions

View File

@ -235,7 +235,7 @@ export function buildApiMessageWithChatId(
}), }),
...(shouldHideKeyboardButtons && { shouldHideKeyboardButtons, isHideKeyboardSelective }), ...(shouldHideKeyboardButtons && { shouldHideKeyboardButtons, isHideKeyboardSelective }),
...(mtpMessage.viaBotId && { viaBotId: buildApiPeerId(mtpMessage.viaBotId, 'user') }), ...(mtpMessage.viaBotId && { viaBotId: buildApiPeerId(mtpMessage.viaBotId, 'user') }),
...(replies?.comments && { repliesThreadInfo: buildThreadInfo(replies, mtpMessage.id, chatId) }), ...(replies && { repliesThreadInfo: buildThreadInfo(replies, mtpMessage.id, chatId) }),
...(postAuthor && { postAuthorTitle: postAuthor }), ...(postAuthor && { postAuthorTitle: postAuthor }),
isProtected, isProtected,
isForwardingAllowed, isForwardingAllowed,
@ -1591,20 +1591,18 @@ function buildThreadInfo(
messageReplies: GramJs.TypeMessageReplies, messageId: number, chatId: string, messageReplies: GramJs.TypeMessageReplies, messageId: number, chatId: string,
): ApiThreadInfo | undefined { ): ApiThreadInfo | undefined {
const { const {
channelId, replies, maxId, readMaxId, recentRepliers, channelId, replies, maxId, readMaxId, recentRepliers, comments,
} = messageReplies; } = messageReplies;
if (!channelId) {
return undefined;
}
const apiChannelId = buildApiPeerId(channelId, 'channel'); const apiChannelId = channelId ? buildApiPeerId(channelId, 'channel') : undefined;
if (apiChannelId === DELETED_COMMENTS_CHANNEL_ID) { if (apiChannelId === DELETED_COMMENTS_CHANNEL_ID) {
return undefined; return undefined;
} }
const isPostThread = chatId !== apiChannelId; const isPostThread = apiChannelId && chatId !== apiChannelId;
return { return {
isComments: comments,
threadId: messageId, threadId: messageId,
...(isPostThread ? { ...(isPostThread ? {
chatId: apiChannelId, chatId: apiChannelId,

View File

@ -850,9 +850,9 @@ export async function markMessagesRead({
} }
export async function requestThreadInfoUpdate({ export async function requestThreadInfoUpdate({
chat, threadId, chat, threadId, originChannelId,
}: { }: {
chat: ApiChat; threadId: number; chat: ApiChat; threadId: number; originChannelId?: string;
}) { }) {
if (threadId === MAIN_THREAD_ID) { if (threadId === MAIN_THREAD_ID) {
return undefined; return undefined;
@ -881,15 +881,18 @@ export async function requestThreadInfoUpdate({
return undefined; return undefined;
} }
const topMessageId = topMessageResult.messages[topMessageResult.messages.length - 1].id;
onUpdate({ onUpdate({
'@type': 'updateThreadInfo', '@type': 'updateThreadInfo',
chatId: discussionChatId, chatId: discussionChatId,
threadId, threadId: topMessageId,
threadInfo: { threadInfo: {
threadId, threadId: topMessageId,
topMessageId: topMessageResult.messages[topMessageResult.messages.length - 1].id, topMessageId,
lastReadInboxMessageId: topMessageResult.readInboxMaxId, lastReadInboxMessageId: topMessageResult.readInboxMaxId,
messagesCount: (repliesResult instanceof GramJs.messages.ChannelMessages) ? repliesResult.count : undefined, messagesCount: (repliesResult instanceof GramJs.messages.ChannelMessages) ? repliesResult.count : undefined,
...(originChannelId ? { originChannelId } : undefined),
}, },
firstMessageId: repliesResult && 'messages' in repliesResult && repliesResult.messages.length firstMessageId: repliesResult && 'messages' in repliesResult && repliesResult.messages.length
? repliesResult.messages[0].id ? repliesResult.messages[0].id
@ -920,6 +923,7 @@ export async function requestThreadInfoUpdate({
const users = topMessageResult.users.map(buildApiUser).filter(Boolean); const users = topMessageResult.users.map(buildApiUser).filter(Boolean);
return { return {
topMessageId,
discussionChatId, discussionChatId,
users, users,
}; };

View File

@ -603,6 +603,14 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
lastReadInboxMessageId: update.readMaxId, lastReadInboxMessageId: update.readMaxId,
}, },
}); });
} else if (update instanceof GramJs.UpdateReadChannelDiscussionOutbox) {
onUpdate({
'@type': 'updateChat',
id: buildApiPeerId(update.channelId, 'channel'),
chat: {
lastReadOutboxMessageId: update.readMaxId,
},
});
} else if ( } else if (
update instanceof GramJs.UpdateDialogPinned update instanceof GramJs.UpdateDialogPinned
&& update.peer instanceof GramJs.DialogPeer && update.peer instanceof GramJs.DialogPeer

View File

@ -491,6 +491,7 @@ export type ApiReactionCustomEmoji = {
export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji; export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji;
export interface ApiThreadInfo { export interface ApiThreadInfo {
isComments?: boolean;
threadId: number; threadId: number;
chatId: string; chatId: string;
topMessageId?: number; topMessageId?: number;

Binary file not shown.

Binary file not shown.

View File

@ -6,6 +6,7 @@
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
margin-inline-end: 0.5rem; margin-inline-end: 0.5rem;
flex-grow: 1;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;

View File

@ -13,7 +13,7 @@ import {
selectCurrentMessageList, selectCurrentMessageList,
selectIsChatWithSelf, selectIsCurrentUserPremium, selectIsChatWithSelf, selectIsCurrentUserPremium,
selectShouldSchedule, selectShouldSchedule,
selectStickerSet, selectStickerSet, selectThreadInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import { copyTextToClipboard } from '../../util/clipboard'; import { copyTextToClipboard } from '../../util/clipboard';
@ -236,8 +236,10 @@ export default memo(withGlobal<OwnProps>(
const { chatId, threadId } = currentMessageList || {}; const { chatId, threadId } = currentMessageList || {};
const chat = chatId && selectChat(global, chatId); const chat = chatId && selectChat(global, chatId);
const sendOptions = chat ? getAllowedAttachmentOptions(chat) : undefined; const sendOptions = chat ? getAllowedAttachmentOptions(chat) : undefined;
const threadInfo = chatId && threadId ? selectThreadInfo(global, chatId, threadId) : undefined;
const isComments = Boolean(threadInfo?.originChannelId);
const canSendStickers = Boolean( const canSendStickers = Boolean(
chat && threadId && getCanPostInChat(chat, threadId) && sendOptions?.canSendStickers, chat && threadId && getCanPostInChat(chat, threadId, isComments) && sendOptions?.canSendStickers,
); );
const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId); const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId);

View File

@ -28,7 +28,7 @@ import {
selectFirstMessageId, selectFirstMessageId,
selectChatScheduledMessages, selectChatScheduledMessages,
selectCurrentMessageIds, selectCurrentMessageIds,
selectIsCurrentUserPremium, selectLastScrollOffset, selectIsCurrentUserPremium, selectLastScrollOffset, selectThreadInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { import {
isChatChannel, isChatChannel,
@ -96,6 +96,7 @@ type StateProps = {
messageIds?: number[]; messageIds?: number[];
messagesById?: Record<number, ApiMessage>; messagesById?: Record<number, ApiMessage>;
firstUnreadId?: number; firstUnreadId?: number;
isComments?: boolean;
isViewportNewest?: boolean; isViewportNewest?: boolean;
isRestricted?: boolean; isRestricted?: boolean;
restrictionReason?: ApiRestrictionReason; restrictionReason?: ApiRestrictionReason;
@ -144,6 +145,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
messageIds, messageIds,
messagesById, messagesById,
firstUnreadId, firstUnreadId,
isComments,
isViewportNewest, isViewportNewest,
threadFirstMessageId, threadFirstMessageId,
isRestricted, isRestricted,
@ -604,6 +606,8 @@ const MessageList: FC<OwnProps & StateProps> = ({
<MessageListContent <MessageListContent
isCurrentUserPremium={isCurrentUserPremium} isCurrentUserPremium={isCurrentUserPremium}
chatId={chatId} chatId={chatId}
isComments={isComments}
isChannelChat={isChannelChat}
messageIds={messageIds || [lastMessage!.id]} messageIds={messageIds || [lastMessage!.id]}
messageGroups={messageGroups || groupMessages([lastMessage!])} messageGroups={messageGroups || groupMessages([lastMessage!])}
isViewportNewest={Boolean(isViewportNewest)} isViewportNewest={Boolean(isViewportNewest)}
@ -645,6 +649,7 @@ export default memo(withGlobal<OwnProps>(
? selectChatScheduledMessages(global, chatId) ? selectChatScheduledMessages(global, chatId)
: selectChatMessages(global, chatId); : selectChatMessages(global, chatId);
const threadTopMessageId = selectThreadTopMessageId(global, chatId, threadId); const threadTopMessageId = selectThreadTopMessageId(global, chatId, threadId);
const threadInfo = selectThreadInfo(global, chatId, threadId);
if ( if (
threadId !== MAIN_THREAD_ID && !chat?.isForum threadId !== MAIN_THREAD_ID && !chat?.isForum
@ -687,6 +692,7 @@ export default memo(withGlobal<OwnProps>(
isBot: Boolean(chatBot), isBot: Boolean(chatBot),
messageIds, messageIds,
messagesById, messagesById,
isComments: Boolean(threadInfo?.originChannelId),
firstUnreadId: selectFirstUnreadId(global, chatId, threadId), firstUnreadId: selectFirstUnreadId(global, chatId, threadId),
isViewportNewest: type !== 'thread' || selectIsViewportNewest(global, chatId, threadId), isViewportNewest: type !== 'thread' || selectIsViewportNewest(global, chatId, threadId),
threadFirstMessageId: selectFirstMessageId(global, chatId, threadId), threadFirstMessageId: selectFirstMessageId(global, chatId, threadId),

View File

@ -1,10 +1,12 @@
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { MessageListType } from '../../global/types'; import type { MessageListType } from '../../global/types';
import { SCHEDULED_WHEN_ONLINE } from '../../config'; import { SCHEDULED_WHEN_ONLINE } from '../../config';
import { MAIN_THREAD_ID } from '../../api/types';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { compact } from '../../util/iteratees'; import { compact } from '../../util/iteratees';
import { formatHumanDate } from '../../util/dateFormat'; import { formatHumanDate } from '../../util/dateFormat';
@ -21,8 +23,6 @@ import useMessageObservers from './hooks/useMessageObservers';
import Message from './message/Message'; import Message from './message/Message';
import SponsoredMessage from './message/SponsoredMessage'; import SponsoredMessage from './message/SponsoredMessage';
import ActionMessage from './ActionMessage'; import ActionMessage from './ActionMessage';
import { getActions } from '../../global';
import { MAIN_THREAD_ID } from '../../api/types';
interface OwnProps { interface OwnProps {
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
@ -33,6 +33,8 @@ interface OwnProps {
isViewportNewest: boolean; isViewportNewest: boolean;
isUnread: boolean; isUnread: boolean;
withUsers: boolean; withUsers: boolean;
isChannelChat: boolean | undefined;
isComments?: boolean;
noAvatars: boolean; noAvatars: boolean;
containerRef: RefObject<HTMLDivElement>; containerRef: RefObject<HTMLDivElement>;
anchorIdRef: { current: string | undefined }; anchorIdRef: { current: string | undefined };
@ -60,7 +62,9 @@ const MessageListContent: FC<OwnProps> = ({
messageGroups, messageGroups,
isViewportNewest, isViewportNewest,
isUnread, isUnread,
isComments,
withUsers, withUsers,
isChannelChat,
noAvatars, noAvatars,
containerRef, containerRef,
anchorIdRef, anchorIdRef,
@ -190,6 +194,10 @@ const MessageListContent: FC<OwnProps> = ({
// Service notifications saved in cache in previous versions may share the same `previousLocalId` // Service notifications saved in cache in previous versions may share the same `previousLocalId`
const key = isServiceNotificationMessage(message) ? `${message.date}_${originalId}` : originalId; const key = isServiceNotificationMessage(message) ? `${message.date}_${originalId}` : originalId;
const noComments = hasLinkedChat === false || !isChannelChat;
const isTopicTopMessage = message.id === threadTopMessageId;
return compact([ return compact([
message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider, message.id === memoUnreadDividerBeforeIdRef.current && unreadDivider,
<Message <Message
@ -200,11 +208,12 @@ const MessageListContent: FC<OwnProps> = ({
observeIntersectionForPlaying={observeIntersectionForPlaying} observeIntersectionForPlaying={observeIntersectionForPlaying}
album={album} album={album}
noAvatars={noAvatars} noAvatars={noAvatars}
withAvatar={position.isLastInGroup && withUsers && !isOwn && !(message.id === threadTopMessageId)} withAvatar={position.isLastInGroup && withUsers && !isOwn && (!isTopicTopMessage || !isComments)}
withSenderName={position.isFirstInGroup && withUsers && !isOwn} withSenderName={position.isFirstInGroup && withUsers && !isOwn}
threadId={threadId} threadId={threadId}
messageListType={type} messageListType={type}
noComments={hasLinkedChat === false} noComments={noComments}
noReplies={!noComments || threadId !== MAIN_THREAD_ID}
appearanceOrder={messageCountToAnimate - ++appearanceIndex} appearanceOrder={messageCountToAnimate - ++appearanceIndex}
isFirstInGroup={position.isFirstInGroup} isFirstInGroup={position.isFirstInGroup}
isLastInGroup={position.isLastInGroup} isLastInGroup={position.isLastInGroup}

View File

@ -40,7 +40,7 @@ import {
selectIsUserBlocked, selectIsUserBlocked,
selectPinnedIds, selectPinnedIds,
selectReplyingToId, selectReplyingToId,
selectTheme, selectTheme, selectThreadInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { import {
getCanPostInChat, getCanPostInChat,
@ -630,7 +630,9 @@ export default memo(withGlobal<OwnProps>(
const pinnedIds = selectPinnedIds(global, chatId, threadId); const pinnedIds = selectPinnedIds(global, chatId, threadId);
const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer;
const canPost = chat && getCanPostInChat(chat, threadId); const threadInfo = selectThreadInfo(global, chatId, threadId);
const isComments = Boolean(threadInfo?.originChannelId);
const canPost = chat && getCanPostInChat(chat, threadId, isComments);
const isBotNotStarted = selectIsChatBotNotStarted(global, chatId); const isBotNotStarted = selectIsChatBotNotStarted(global, chatId);
const isPinnedMessageList = messageListType === 'pinned'; const isPinnedMessageList = messageListType === 'pinned';
const isScheduledMessageList = messageListType === 'scheduled'; const isScheduledMessageList = messageListType === 'scheduled';

View File

@ -89,6 +89,7 @@ type StateProps = {
isRightColumnShown?: boolean; isRightColumnShown?: boolean;
audioMessage?: ApiMessage; audioMessage?: ApiMessage;
messagesCount?: number; messagesCount?: number;
isComments?: boolean;
isChatWithSelf?: boolean; isChatWithSelf?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
hasButtonInHeader?: boolean; hasButtonInHeader?: boolean;
@ -116,6 +117,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
audioMessage, audioMessage,
chat, chat,
messagesCount, messagesCount,
isComments,
isChatWithSelf, isChatWithSelf,
lastSyncTime, lastSyncTime,
hasButtonInHeader, hasButtonInHeader,
@ -340,7 +342,8 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
{renderBackButton()} {renderBackButton()}
<h3> <h3>
{messagesCount !== undefined ? ( {messagesCount !== undefined ? (
messageListType === 'thread' ? (lang('CommentsCount', messagesCount, 'i')) messageListType === 'thread' ? (
lang(isComments ? 'CommentsCount' : 'Replies', messagesCount, 'i'))
: messageListType === 'pinned' ? (lang('PinnedMessagesCount', messagesCount, 'i')) : messageListType === 'pinned' ? (lang('PinnedMessagesCount', messagesCount, 'i'))
: messageListType === 'scheduled' ? ( : messageListType === 'scheduled' ? (
isChatWithSelf ? lang('Reminders') : lang('messages', messagesCount, 'i') isChatWithSelf ? lang('Reminders') : lang('messages', messagesCount, 'i')
@ -422,13 +425,15 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
{renderInfo()} {renderInfo()}
</Transition> </Transition>
<GroupCallTopPane {threadId === MAIN_THREAD_ID && !chat?.isForum && (
hasPinnedOffset={ <GroupCallTopPane
(shouldRenderPinnedMessage && Boolean(renderingPinnedMessage)) hasPinnedOffset={
(shouldRenderPinnedMessage && Boolean(renderingPinnedMessage))
|| (shouldRenderAudioPlayer && Boolean(renderingAudioMessage)) || (shouldRenderAudioPlayer && Boolean(renderingAudioMessage))
} }
chatId={chatId} chatId={chatId}
/> />
)}
{shouldRenderPinnedMessage && renderingPinnedMessage && ( {shouldRenderPinnedMessage && renderingPinnedMessage && (
<HeaderPinnedMessage <HeaderPinnedMessage
@ -540,12 +545,14 @@ export default memo(withGlobal<OwnProps>(
const pinnedMessageId = selectThreadTopMessageId(global, chatId, threadId); const pinnedMessageId = selectThreadTopMessageId(global, chatId, threadId);
const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined; const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined;
const topMessageSender = message ? selectForwardedSender(global, message) : undefined; const topMessageSender = message ? selectForwardedSender(global, message) : undefined;
const threadInfo = selectThreadInfo(global, chatId, threadId);
return { return {
...state, ...state,
pinnedMessageIds: pinnedMessageId, pinnedMessageIds: pinnedMessageId,
canUnpin: false, canUnpin: false,
topMessageSender, topMessageSender,
isComments: Boolean(threadInfo?.originChannelId),
}; };
} }

View File

@ -25,16 +25,16 @@ const CommentButton: FC<OwnProps> = ({
threadInfo, threadInfo,
disabled, disabled,
}) => { }) => {
const { openChat } = getActions(); const { openComments } = getActions();
const lang = useLang(); const lang = useLang();
const { const {
threadId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, threadId, chatId, messagesCount, lastMessageId, lastReadInboxMessageId, recentReplierIds, originChannelId,
} = threadInfo; } = threadInfo;
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
openChat({ id: chatId, threadId }); openComments({ id: chatId, threadId, originChannelId });
}, [openChat, chatId, threadId]); }, [openComments, chatId, threadId, originChannelId]);
const recentRepliers = useMemo(() => { const recentRepliers = useMemo(() => {
if (!recentReplierIds?.length) { if (!recentReplierIds?.length) {

View File

@ -6,7 +6,7 @@ import { getActions, getGlobal, withGlobal } from '../../../global';
import type { MessageListType } from '../../../global/types'; import type { MessageListType } from '../../../global/types';
import type { import type {
ApiAvailableReaction, ApiStickerSetInfo, ApiMessage, ApiStickerSet, ApiChatReactions, ApiReaction, ApiAvailableReaction, ApiStickerSetInfo, ApiMessage, ApiStickerSet, ApiChatReactions, ApiReaction, ApiThreadInfo,
} from '../../../api/types'; } from '../../../api/types';
import type { IAlbum, IAnchorPosition } from '../../../types'; import type { IAlbum, IAnchorPosition } from '../../../types';
@ -48,8 +48,10 @@ export type OwnProps = {
album?: IAlbum; album?: IAlbum;
anchor: IAnchorPosition; anchor: IAnchorPosition;
messageListType: MessageListType; messageListType: MessageListType;
noReplies?: boolean;
onClose: () => void; onClose: () => void;
onCloseAnimationEnd: () => void; onCloseAnimationEnd: () => void;
repliesThreadInfo?: ApiThreadInfo;
}; };
type StateProps = { type StateProps = {
@ -107,6 +109,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canReschedule, canReschedule,
canReply, canReply,
canPin, canPin,
repliesThreadInfo,
canUnpin, canUnpin,
canDelete, canDelete,
canReport, canReport,
@ -129,11 +132,13 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canRevote, canRevote,
canClosePoll, canClosePoll,
activeDownloads, activeDownloads,
noReplies,
canShowSeenBy, canShowSeenBy,
canScheduleUntilOnline, canScheduleUntilOnline,
threadId, threadId,
}) => { }) => {
const { const {
openChat,
setReplyingToId, setReplyingToId,
setEditingId, setEditingId,
pinMessage, pinMessage,
@ -254,6 +259,14 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu(); closeMenu();
}, [setReplyingToId, message.id, closeMenu]); }, [setReplyingToId, message.id, closeMenu]);
const handleOpenThread = useCallback(() => {
openChat({
id: message.chatId,
threadId: message.id,
});
closeMenu();
}, [closeMenu, message.chatId, message.id, openChat]);
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
setEditingId({ messageId: message.id }); setEditingId({ messageId: message.id });
closeMenu(); closeMenu();
@ -411,6 +424,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canDelete={canDelete} canDelete={canDelete}
canReport={canReport} canReport={canReport}
canPin={canPin} canPin={canPin}
repliesThreadInfo={repliesThreadInfo}
canUnpin={canUnpin} canUnpin={canUnpin}
canEdit={canEdit} canEdit={canEdit}
canForward={canForward} canForward={canForward}
@ -428,6 +442,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
customEmojiSets={customEmojiSets} customEmojiSets={customEmojiSets}
isDownloading={isDownloading} isDownloading={isDownloading}
seenByRecentUsers={seenByRecentUsers} seenByRecentUsers={seenByRecentUsers}
noReplies={noReplies}
onOpenThread={handleOpenThread}
onReply={handleReply} onReply={handleReply}
onEdit={handleEdit} onEdit={handleEdit}
onPin={handlePin} onPin={handlePin}

View File

@ -170,6 +170,7 @@ type OwnProps =
threadId: number; threadId: number;
messageListType: MessageListType; messageListType: MessageListType;
noComments: boolean; noComments: boolean;
noReplies: boolean;
appearanceOrder: number; appearanceOrder: number;
memoFirstUnreadIdRef: { current: number | undefined }; memoFirstUnreadIdRef: { current: number | undefined };
} }
@ -265,6 +266,7 @@ const Message: FC<OwnProps & StateProps> = ({
withAvatar, withAvatar,
withSenderName, withSenderName,
noComments, noComments,
noReplies,
appearanceOrder, appearanceOrder,
isFirstInGroup, isFirstInGroup,
isPremium, isPremium,
@ -466,6 +468,7 @@ const Message: FC<OwnProps & StateProps> = ({
handleAudioPlay, handleAudioPlay,
handleAlbumMediaClick, handleAlbumMediaClick,
handleMetaClick, handleMetaClick,
handleOpenThread,
handleReadMedia, handleReadMedia,
handleCancelUpload, handleCancelUpload,
handleVoteSend, handleVoteSend,
@ -523,7 +526,7 @@ const Message: FC<OwnProps & StateProps> = ({
message.hasUnreadMention && 'has-unread-mention', message.hasUnreadMention && 'has-unread-mention',
isSelected && 'is-selected', isSelected && 'is-selected',
isInSelectMode && 'is-in-selection-mode', isInSelectMode && 'is-in-selection-mode',
isThreadTop && 'is-thread-top', isThreadTop && !withAvatar && 'is-thread-top',
Boolean(message.inlineButtons) && 'has-inline-buttons', Boolean(message.inlineButtons) && 'has-inline-buttons',
isSwiped && 'is-swiped', isSwiped && 'is-swiped',
transitionClassNames, transitionClassNames,
@ -546,7 +549,7 @@ const Message: FC<OwnProps & StateProps> = ({
isCustomShape, isCustomShape,
isLastInGroup, isLastInGroup,
asForwarded, asForwarded,
hasThread, hasThread: hasThread && !noComments,
forceSenderName, forceSenderName,
hasComments: repliesThreadInfo && repliesThreadInfo.messagesCount > 0, hasComments: repliesThreadInfo && repliesThreadInfo.messagesCount > 0,
hasActionButton: canForward || canFocus, hasActionButton: canForward || canFocus,
@ -708,11 +711,14 @@ const Message: FC<OwnProps & StateProps> = ({
const meta = ( const meta = (
<MessageMeta <MessageMeta
message={message} message={message}
noReplies={noReplies}
repliesThreadInfo={repliesThreadInfo}
outgoingStatus={outgoingStatus} outgoingStatus={outgoingStatus}
signature={signature} signature={signature}
withReactionOffset={reactionsPosition === 'inside'} withReactionOffset={reactionsPosition === 'inside'}
availableReactions={availableReactions} availableReactions={availableReactions}
onClick={handleMetaClick} onClick={handleMetaClick}
onOpenThread={handleOpenThread}
/> />
); );
@ -1200,6 +1206,8 @@ const Message: FC<OwnProps & StateProps> = ({
messageListType={messageListType} messageListType={messageListType}
onClose={handleContextMenuClose} onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide} onCloseAnimationEnd={handleContextMenuHide}
repliesThreadInfo={repliesThreadInfo}
noReplies={noReplies}
/> />
)} )}
</div> </div>

View File

@ -5,7 +5,14 @@ import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import type { import type {
ApiAvailableReaction, ApiChatReactions, ApiMessage, ApiReaction, ApiSponsoredMessage, ApiStickerSet, ApiUser, ApiAvailableReaction,
ApiChatReactions,
ApiMessage,
ApiReaction,
ApiSponsoredMessage,
ApiStickerSet,
ApiThreadInfo,
ApiUser,
} from '../../../api/types'; } from '../../../api/types';
import type { IAnchorPosition } from '../../../types'; import type { IAnchorPosition } from '../../../types';
@ -39,6 +46,7 @@ type OwnProps = {
maxUniqueReactions?: number; maxUniqueReactions?: number;
canReschedule?: boolean; canReschedule?: boolean;
canReply?: boolean; canReply?: boolean;
repliesThreadInfo?: ApiThreadInfo;
canPin?: boolean; canPin?: boolean;
canUnpin?: boolean; canUnpin?: boolean;
canDelete?: boolean; canDelete?: boolean;
@ -62,9 +70,11 @@ type OwnProps = {
isDownloading?: boolean; isDownloading?: boolean;
canShowSeenBy?: boolean; canShowSeenBy?: boolean;
seenByRecentUsers?: ApiUser[]; seenByRecentUsers?: ApiUser[];
noReplies?: boolean;
hasCustomEmoji?: boolean; hasCustomEmoji?: boolean;
customEmojiSets?: ApiStickerSet[]; customEmojiSets?: ApiStickerSet[];
onReply?: () => void; onReply?: () => void;
onOpenThread?: VoidFunction;
onEdit?: () => void; onEdit?: () => void;
onPin?: () => void; onPin?: () => void;
onUnpin?: () => void; onUnpin?: () => void;
@ -110,6 +120,7 @@ const MessageContextMenu: FC<OwnProps> = ({
canBuyPremium, canBuyPremium,
canReply, canReply,
canEdit, canEdit,
noReplies,
canPin, canPin,
canUnpin, canUnpin,
canDelete, canDelete,
@ -125,6 +136,7 @@ const MessageContextMenu: FC<OwnProps> = ({
canRevote, canRevote,
canClosePoll, canClosePoll,
isDownloading, isDownloading,
repliesThreadInfo,
canShowSeenBy, canShowSeenBy,
canShowReactionsCount, canShowReactionsCount,
canShowReactionList, canShowReactionList,
@ -132,6 +144,7 @@ const MessageContextMenu: FC<OwnProps> = ({
hasCustomEmoji, hasCustomEmoji,
customEmojiSets, customEmojiSets,
onReply, onReply,
onOpenThread,
onEdit, onEdit,
onPin, onPin,
onUnpin, onUnpin,
@ -294,6 +307,11 @@ const MessageContextMenu: FC<OwnProps> = ({
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem> <MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>
)} )}
{canReply && <MenuItem icon="reply" onClick={onReply}>{lang('Reply')}</MenuItem>} {canReply && <MenuItem icon="reply" onClick={onReply}>{lang('Reply')}</MenuItem>}
{!noReplies && Boolean(repliesThreadInfo?.messagesCount) && (
<MenuItem icon="replies" onClick={onOpenThread}>
{lang('Conversation.ContextViewReplies', repliesThreadInfo!.messagesCount, 'i')}
</MenuItem>
)}
{canEdit && <MenuItem icon="edit" onClick={onEdit}>{lang('Edit')}</MenuItem>} {canEdit && <MenuItem icon="edit" onClick={onEdit}>{lang('Edit')}</MenuItem>}
{canFaveSticker && ( {canFaveSticker && (
<MenuItem icon="favorite" onClick={onFaveSticker}>{lang('AddToFavorites')}</MenuItem> <MenuItem icon="favorite" onClick={onFaveSticker}>{lang('AddToFavorites')}</MenuItem>

View File

@ -16,7 +16,8 @@
.message-time, .message-time,
.message-imported, .message-imported,
.message-signature, .message-signature,
.message-views { .message-views,
.message-replies {
font-size: 0.75rem; font-size: 0.75rem;
white-space: nowrap; white-space: nowrap;
} }
@ -55,6 +56,12 @@
top: -0.0625rem; top: -0.0625rem;
} }
.icon-reply-filled {
margin-left: 0.125rem;
margin-right: 0.375rem;
font-size: 0.75rem;
}
.has-solid-background & { .has-solid-background & {
color: rgba(var(--color-text-meta-rgb), 0.75); color: rgba(var(--color-text-meta-rgb), 0.75);
background: none; background: none;

View File

@ -2,7 +2,9 @@ import type { FC } from '../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../lib/teact/teact'; import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { ApiAvailableReaction, ApiMessage, ApiMessageOutgoingStatus } from '../../../api/types'; import type {
ApiAvailableReaction, ApiMessage, ApiMessageOutgoingStatus, ApiThreadInfo,
} from '../../../api/types';
import { formatDateTimeToString, formatTime } from '../../../util/dateFormat'; import { formatDateTimeToString, formatTime } from '../../../util/dateFormat';
import { formatIntegerCompact } from '../../../util/textFormat'; import { formatIntegerCompact } from '../../../util/textFormat';
@ -13,6 +15,7 @@ import useFlag from '../../../hooks/useFlag';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import MessageOutgoingStatus from '../../common/MessageOutgoingStatus'; import MessageOutgoingStatus from '../../common/MessageOutgoingStatus';
import AnimatedCounter from '../../common/AnimatedCounter';
import './MessageMeta.scss'; import './MessageMeta.scss';
@ -22,7 +25,10 @@ type OwnProps = {
outgoingStatus?: ApiMessageOutgoingStatus; outgoingStatus?: ApiMessageOutgoingStatus;
signature?: string; signature?: string;
availableReactions?: ApiAvailableReaction[]; availableReactions?: ApiAvailableReaction[];
noReplies?: boolean;
repliesThreadInfo?: ApiThreadInfo;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onOpenThread: () => void;
}; };
const MessageMeta: FC<OwnProps> = ({ const MessageMeta: FC<OwnProps> = ({
@ -30,7 +36,10 @@ const MessageMeta: FC<OwnProps> = ({
outgoingStatus, outgoingStatus,
signature, signature,
withReactionOffset, withReactionOffset,
repliesThreadInfo,
noReplies,
onClick, onClick,
onOpenThread,
}) => { }) => {
const { showNotification } = getActions(); const { showNotification } = getActions();
const lang = useLang(); const lang = useLang();
@ -44,6 +53,11 @@ const MessageMeta: FC<OwnProps> = ({
}); });
}; };
function handleOpenThread(e: React.MouseEvent) {
e.stopPropagation();
onOpenThread();
}
const title = useMemo(() => { const title = useMemo(() => {
if (!isActivated) return undefined; if (!isActivated) return undefined;
const createDateTime = formatDateTimeToString(message.date * 1000, lang.code); const createDateTime = formatDateTimeToString(message.date * 1000, lang.code);
@ -84,6 +98,14 @@ const MessageMeta: FC<OwnProps> = ({
<i className="icon-channelviews" /> <i className="icon-channelviews" />
</> </>
)} )}
{!noReplies && Boolean(repliesThreadInfo?.messagesCount) && (
<span onClick={handleOpenThread}>
<span className="message-replies">
<AnimatedCounter text={formatIntegerCompact(repliesThreadInfo!.messagesCount!)} />
</span>
<i className="icon-reply-filled" />
</span>
)}
{signature && ( {signature && (
<span className="message-signature">{renderText(signature)}</span> <span className="message-signature">{renderText(signature)}</span>
)} )}

View File

@ -160,6 +160,13 @@ export default function useInnerHandlers(
selectMessage(e, groupedId); selectMessage(e, groupedId);
}, [selectMessage, groupedId]); }, [selectMessage, groupedId]);
const handleOpenThread = useCallback(() => {
openChat({
id: message.chatId,
threadId: message.id,
});
}, [message.chatId, message.id, openChat]);
const handleTopicChipClick = useCallback(() => { const handleTopicChipClick = useCallback(() => {
if (!messageTopic) return; if (!messageTopic) return;
focusMessage({ focusMessage({
@ -178,6 +185,7 @@ export default function useInnerHandlers(
handleAudioPlay, handleAudioPlay,
handleAlbumMediaClick, handleAlbumMediaClick,
handleMetaClick: selectWithGroupedId, handleMetaClick: selectWithGroupedId,
handleOpenThread,
handleReadMedia, handleReadMedia,
handleCancelUpload, handleCancelUpload,
handleVoteSend, handleVoteSend,

View File

@ -11,7 +11,7 @@ import {
selectIsChatWithBot, selectIsChatWithBot,
selectCurrentMessageList, selectCurrentMessageList,
selectCanScheduleUntilOnline, selectCanScheduleUntilOnline,
selectIsChatWithSelf, selectIsChatWithSelf, selectThreadInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers'; import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -155,7 +155,9 @@ export default memo(withGlobal(
const chat = chatId ? selectChat(global, chatId) : undefined; const chat = chatId ? selectChat(global, chatId) : undefined;
const isChatWithBot = chat ? selectIsChatWithBot(global, chat) : undefined; const isChatWithBot = chat ? selectIsChatWithBot(global, chat) : undefined;
const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId); const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId);
const canPostInChat = Boolean(chat) && Boolean(threadId) && getCanPostInChat(chat, threadId); const threadInfo = chatId && threadId ? selectThreadInfo(global, chatId, threadId) : undefined;
const isComments = Boolean(threadInfo?.originChannelId);
const canPostInChat = Boolean(chat) && Boolean(threadId) && getCanPostInChat(chat, threadId, isComments);
return { return {
query, query,

View File

@ -276,9 +276,9 @@ const ManageUser: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { userId }): StateProps => { (global, { userId }): StateProps => {
const user = selectUser(global, userId); const user = selectUser(global, userId);
const chat = selectChat(global, userId)!; const chat = selectChat(global, userId);
const { progress } = selectTabState(global).management; const { progress } = selectTabState(global).management;
const isMuted = selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)); const isMuted = chat && selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global));
return { return {
user, progress, isMuted, user, progress, isMuted,

View File

@ -55,7 +55,7 @@ import {
selectChatFolder, selectSupportChat, selectChatByUsername, selectChatFolder, selectSupportChat, selectChatByUsername,
selectCurrentMessageList, selectThreadInfo, selectCurrentChat, selectLastServiceNotification, selectCurrentMessageList, selectThreadInfo, selectCurrentChat, selectLastServiceNotification,
selectVisibleUsers, selectUserByPhoneNumber, selectDraft, selectThreadTopMessageId, selectVisibleUsers, selectUserByPhoneNumber, selectDraft, selectThreadTopMessageId,
selectTabState, selectThread, selectTabState, selectThread, selectThreadOriginChat,
} from '../../selectors'; } from '../../selectors';
import { buildCollectionByKey, omit } from '../../../util/iteratees'; import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { debounce, pause, throttle } from '../../../util/schedulers'; import { debounce, pause, throttle } from '../../../util/schedulers';
@ -142,10 +142,38 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
actions.requestChatUpdate({ chatId: id }); actions.requestChatUpdate({ chatId: id });
} }
if (threadId !== MAIN_THREAD_ID) {
actions.requestThreadInfoUpdate({ chatId: id, threadId });
}
});
addActionHandler('openComments', async (global, actions, payload): Promise<void> => {
const {
id, threadId, originChannelId, tabId = getCurrentTabId(),
} = payload;
if (threadId !== MAIN_THREAD_ID) { if (threadId !== MAIN_THREAD_ID) {
const topMessageId = selectThreadTopMessageId(global, id, threadId); const topMessageId = selectThreadTopMessageId(global, id, threadId);
if (!topMessageId) { if (!topMessageId) {
actions.requestThreadInfoUpdate({ chatId: id, threadId }); const chat = selectThreadOriginChat(global, id, threadId);
if (!chat) {
return;
}
actions.openChat({ id: TMP_CHAT_ID, tabId });
const result = await callApi('requestThreadInfoUpdate', { chat, threadId, originChannelId });
if (!result) {
actions.openPreviousChat({ tabId });
return;
}
global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id'));
setGlobal(global);
actions.openChat({ id, threadId: result.topMessageId, tabId });
} else {
actions.openChat({ id, threadId: topMessageId, tabId });
} }
} }
}); });

View File

@ -68,7 +68,6 @@ import {
selectReplyingToId, selectReplyingToId,
selectEditingId, selectEditingId,
selectDraft, selectDraft,
selectThreadOriginChat,
selectThreadTopMessageId, selectThreadTopMessageId,
selectEditingScheduledId, selectEditingScheduledId,
selectEditingMessage, selectEditingMessage,
@ -239,12 +238,17 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
} }
const chat = selectChat(global, chatId)!; const chat = selectChat(global, chatId)!;
const replyingToTopId = chat.isForum ? selectThreadTopMessageId(global, chatId, threadId) : undefined; const replyingToId = selectReplyingToId(global, chatId, threadId);
const replyingToMessage = replyingToId ? selectChatMessage(global, chatId, replyingToId) : undefined;
const replyingToTopId = chat.isForum
? selectThreadTopMessageId(global, chatId, threadId)
: replyingToMessage?.replyToTopMessageId || replyingToMessage?.replyToMessageId;
const params = { const params = {
...payload, ...payload,
chat, chat,
replyingTo: selectReplyingToId(global, chatId, threadId), replyingTo: replyingToId,
replyingToTopId, replyingToTopId,
noWebPage: selectNoWebPage(global, chatId, threadId), noWebPage: selectNoWebPage(global, chatId, threadId),
sendAs: selectSendAs(global, chatId), sendAs: selectSendAs(global, chatId),
@ -549,7 +553,7 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn
} }
const { chatId, threadId } = currentMessageList; const { chatId, threadId } = currentMessageList;
const chat = selectThreadOriginChat(global, chatId, threadId); const chat = selectChat(global, chatId);
if (!chat) { if (!chat) {
return undefined; return undefined;
} }
@ -853,7 +857,7 @@ addActionHandler('rescheduleMessage', (global, actions, payload): ActionReturnTy
addActionHandler('requestThreadInfoUpdate', async (global, actions, payload): Promise<void> => { addActionHandler('requestThreadInfoUpdate', async (global, actions, payload): Promise<void> => {
const { chatId, threadId } = payload; const { chatId, threadId } = payload;
const chat = selectThreadOriginChat(global, chatId, threadId); const chat = selectChat(global, chatId);
if (!chat) { if (!chat) {
return; return;
} }
@ -939,7 +943,7 @@ async function loadViewportMessages<T extends GlobalState>(
global = getGlobal(); global = getGlobal();
const result = await callApi('fetchMessages', { const result = await callApi('fetchMessages', {
chat: selectThreadOriginChat(global, chatId, threadId)!, chat: selectChat(global, chatId)!,
offsetId, offsetId,
addOffset, addOffset,
limit: MESSAGE_LIST_SLICE, limit: MESSAGE_LIST_SLICE,

View File

@ -105,8 +105,6 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
const activeThreadId = currentThreadId || MAIN_THREAD_ID; const activeThreadId = currentThreadId || MAIN_THREAD_ID;
const threadInfo = currentThreadId && currentChatId const threadInfo = currentThreadId && currentChatId
? selectThreadInfo(global, currentChatId, currentThreadId) : undefined; ? selectThreadInfo(global, currentChatId, currentThreadId) : undefined;
// TODO Fix comments chat id, or refetch chat thread here
const activeCurrentChatId = threadInfo?.originChannelId || currentChatId;
// Memoize drafts // Memoize drafts
const draftChatIds = Object.keys(global.messages.byChatId); const draftChatIds = Object.keys(global.messages.byChatId);
// eslint-disable-next-line @typescript-eslint/no-loop-func // eslint-disable-next-line @typescript-eslint/no-loop-func
@ -119,14 +117,14 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
return acc; return acc;
}, {}); }, {});
const currentChat = activeCurrentChatId ? global.chats.byId[activeCurrentChatId] : undefined; const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined;
if (activeCurrentChatId && currentChat) { if (currentChatId && currentChat) {
const result = await loadTopMessages(currentChat, activeThreadId, threadInfo?.lastReadInboxMessageId); const result = await loadTopMessages(currentChat, activeThreadId, threadInfo?.lastReadInboxMessageId);
global = getGlobal(); global = getGlobal();
const { chatId: newCurrentChatId } = selectCurrentMessageList(global, tabId) || {}; const { chatId: newCurrentChatId } = selectCurrentMessageList(global, tabId) || {};
if (result && newCurrentChatId === currentChatId) { if (result && newCurrentChatId === currentChatId) {
const currentChatMessages = selectChatMessages(global, activeCurrentChatId); const currentChatMessages = selectChatMessages(global, currentChatId);
const localMessages = currentChatId === SERVICE_NOTIFICATIONS_USER_ID const localMessages = currentChatId === SERVICE_NOTIFICATIONS_USER_ID
? global.serviceNotifications.filter(({ isDeleted }) => !isDeleted).map(({ message }) => message) ? global.serviceNotifications.filter(({ isDeleted }) => !isDeleted).map(({ message }) => message)
: []; : [];
@ -158,18 +156,20 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
wasReset = true; wasReset = true;
} }
global = addChatMessagesById(global, activeCurrentChatId, byId); global = addChatMessagesById(global, currentChatId, byId);
global = updateListedIds(global, activeCurrentChatId, activeThreadId, listedIds); global = updateListedIds(global, currentChatId, activeThreadId, listedIds);
// eslint-disable-next-line @typescript-eslint/no-loop-func // eslint-disable-next-line @typescript-eslint/no-loop-func
Object.values(global.byTabId).forEach(({ id: otherTabId }) => { Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
const { chatId: otherChatId, threadId: otherThreadId } = selectCurrentMessageList(global, otherTabId) || {}; const { chatId: otherChatId, threadId: otherThreadId } = selectCurrentMessageList(global, otherTabId) || {};
if (otherChatId === activeCurrentChatId && otherThreadId === activeThreadId) { if (otherChatId === currentChatId && otherThreadId === activeThreadId) {
global = safeReplaceViewportIds(global, activeCurrentChatId, activeThreadId, listedIds, otherTabId); global = safeReplaceViewportIds(global, currentChatId, activeThreadId, listedIds, otherTabId);
} }
}); });
global = updateChats(global, buildCollectionByKey(result.chats, 'id')); global = updateChats(global, buildCollectionByKey(result.chats, 'id'));
global = updateUsers(global, buildCollectionByKey(result.users, 'id')); global = updateUsers(global, buildCollectionByKey(result.users, 'id'));
global = updateThreadInfos(global, activeCurrentChatId, result.repliesThreadInfos); if (result.repliesThreadInfos.length) {
global = updateThreadInfos(global, currentChatId, result.repliesThreadInfos);
}
areMessagesLoaded = true; areMessagesLoaded = true;
} }
@ -184,10 +184,10 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
setGlobal(global); setGlobal(global);
if (currentChat?.isForum) { if (currentChat?.isForum) {
actions.loadTopics({ chatId: activeCurrentChatId!, force: true }); actions.loadTopics({ chatId: currentChatId!, force: true });
if (currentThreadId && currentThreadId !== MAIN_THREAD_ID) { if (currentThreadId && currentThreadId !== MAIN_THREAD_ID) {
actions.loadTopicById({ actions.loadTopicById({
chatId: activeCurrentChatId!, topicId: currentThreadId, shouldCloseChatOnError: true, chatId: currentChatId!, topicId: currentThreadId, shouldCloseChatOnError: true,
}); });
} }
} }

View File

@ -1,9 +1,10 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import type { ApiUpdateChat } from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config'; import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config';
import { buildCollectionByKey, pick } from '../../../util/iteratees'; import { buildCollectionByKey, omit, pick } from '../../../util/iteratees';
import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications'; import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications';
import { import {
updateChat, updateChat,
@ -28,7 +29,15 @@ const TYPING_STATUS_CLEAR_DELAY = 6000; // 6 seconds
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
switch (update['@type']) { switch (update['@type']) {
case 'updateChat': { case 'updateChat': {
const { isForum: prevIsForum } = selectChat(global, update.id) || {}; const { isForum: prevIsForum, lastReadOutboxMessageId } = selectChat(global, update.id) || {};
if (update.chat.lastReadOutboxMessageId && lastReadOutboxMessageId
&& update.chat.lastReadOutboxMessageId < lastReadOutboxMessageId) {
update = {
...update,
chat: omit(update.chat, ['lastReadInboxMessageId']),
};
}
global = updateChat(global, update.id, update.chat, update.newProfilePhoto); global = updateChat(global, update.id, update.chat, update.newProfilePhoto);
setGlobal(global); setGlobal(global);
@ -47,8 +56,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
Object.values(global.byTabId).forEach(({ id: tabId }) => { Object.values(global.byTabId).forEach(({ id: tabId }) => {
const { chatId: currentChatId } = selectCurrentMessageList(global, tabId) || {}; const { chatId: currentChatId } = selectCurrentMessageList(global, tabId) || {};
const chatUpdate = update as ApiUpdateChat;
// The property `isForum` was changed in another client // The property `isForum` was changed in another client
if (currentChatId === update.id && 'isForum' in update.chat && prevIsForum !== update.chat.isForum) { if (currentChatId === chatUpdate.id
&& 'isForum' in chatUpdate.chat && prevIsForum !== chatUpdate.chat.isForum) {
if (prevIsForum) { if (prevIsForum) {
actions.closeForumPanel({ tabId }); actions.closeForumPanel({ tabId });
} }

View File

@ -108,7 +108,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
const { threadInfo } = selectThreadByMessage(global, message as ApiMessage) || {}; const { threadInfo } = selectThreadByMessage(global, message as ApiMessage) || {};
if (threadInfo) { if (threadInfo && !isLocal) {
actions.requestThreadInfoUpdate({ chatId, threadId: threadInfo.threadId }); actions.requestThreadInfoUpdate({ chatId, threadId: threadInfo.threadId });
} }
@ -362,6 +362,17 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.loadTopicById({ chatId, topicId: threadId }); 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),
});
}
}
setGlobal(global); setGlobal(global);
break; break;
@ -758,8 +769,13 @@ function updateListedAndViewportIds<T extends GlobalState>(
global = replaceThreadParam(global, chatId, threadInfo.threadId, 'threadInfo', { global = replaceThreadParam(global, chatId, threadInfo.threadId, 'threadInfo', {
...threadInfo, ...threadInfo,
lastMessageId: message.id, lastMessageId: message.id,
messagesCount: (threadInfo.messagesCount || 0) + 1,
}); });
if (!isMessageLocal(message)) {
global = updateThreadInfo(global, chatId, threadInfo.threadId, {
messagesCount: (threadInfo.messagesCount || 0) + 1,
});
}
} }
if (isUnreadChatNotLoaded) { if (isUnreadChatNotLoaded) {

View File

@ -3,7 +3,7 @@ import { addCallback, removeCallback } from '../lib/teact/teactn';
import { addActionHandler, getGlobal } from './index'; import { addActionHandler, getGlobal } from './index';
import type { ActionReturnType, GlobalState } from './types'; import type { ActionReturnType, GlobalState, MessageList } from './types';
import type { ApiChat, ApiUser } from '../api/types'; import type { ApiChat, ApiUser } from '../api/types';
import { MAIN_THREAD_ID } from '../api/types'; import { MAIN_THREAD_ID } from '../api/types';
@ -27,7 +27,7 @@ import {
} from '../util/iteratees'; } from '../util/iteratees';
import { import {
selectChat, selectChat,
selectCurrentMessageList, selectCurrentMessageList, selectThreadOriginChat,
selectVisibleUsers, selectVisibleUsers,
} from './selectors'; } from './selectors';
import { hasStoredSession } from '../util/sessions'; import { hasStoredSession } from '../util/sessions';
@ -459,8 +459,19 @@ function reduceChats<T extends GlobalState>(global: T): GlobalState['chats'] {
const { chats: { byId }, currentUserId } = global; const { chats: { byId }, currentUserId } = global;
const currentChatIds = compact( const currentChatIds = compact(
Object.values(global.byTabId) Object.values(global.byTabId)
.map(({ id: tabId }) => selectCurrentMessageList(global, tabId)), .flatMap(({ id: tabId }): MessageList[] | undefined => {
).map(({ chatId }) => chatId).filter((chatId) => isUserId(chatId)); 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(({ chatId }) => chatId);
const idsToSave = unique([ const idsToSave = unique([
...currentUserId ? [currentUserId] : [], ...currentUserId ? [currentUserId] : [],

View File

@ -177,7 +177,7 @@ export function isUserRightBanned(chat: ApiChat, key: keyof ApiChatBannedRights)
); );
} }
export function getCanPostInChat(chat: ApiChat, threadId: number) { export function getCanPostInChat(chat: ApiChat, threadId: number, isComments?: boolean) {
if (threadId !== MAIN_THREAD_ID) { if (threadId !== MAIN_THREAD_ID) {
if (chat.isForum) { if (chat.isForum) {
if (chat.isNotJoined) { if (chat.isNotJoined) {
@ -189,10 +189,10 @@ export function getCanPostInChat(chat: ApiChat, threadId: number) {
return false; return false;
} }
} }
return true; // TODO[forums] legacy value, check that again
} }
if (chat.isRestricted || chat.isForbidden || chat.migratedTo || chat.isNotJoined || isChatWithRepliesBot(chat.id)) { if (chat.isRestricted || chat.isForbidden || chat.migratedTo
|| (!isComments && chat.isNotJoined) || isChatWithRepliesBot(chat.id)) {
return false; return false;
} }

View File

@ -10,7 +10,6 @@ import { cloneDeep } from '../util/iteratees';
import { replaceTabThreadParam, replaceThreadParam, updatePasscodeSettings } from './reducers'; import { replaceTabThreadParam, replaceThreadParam, updatePasscodeSettings } from './reducers';
import { clearStoredSession } from '../util/sessions'; import { clearStoredSession } from '../util/sessions';
import { parseLocationHash } from '../util/routing'; import { parseLocationHash } from '../util/routing';
import { MAIN_THREAD_ID } from '../api/types';
import { selectTabState, selectThreadParam } from './selectors'; import { selectTabState, selectThreadParam } from './selectors';
import { Bundles, loadBundle } from '../util/moduleLoader'; import { Bundles, loadBundle } from '../util/moduleLoader';
import { getCurrentTabId, reestablishMasterToSelf } from '../util/establishMultitabRole'; import { getCurrentTabId, reestablishMasterToSelf } from '../util/establishMultitabRole';
@ -68,20 +67,24 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => {
} }
Object.keys(global.messages.byChatId).forEach((chatId) => { Object.keys(global.messages.byChatId).forEach((chatId) => {
const lastViewportIds = selectThreadParam(global, chatId, MAIN_THREAD_ID, 'lastViewportIds'); const threadsById = global.messages.byChatId[chatId].threadsById;
// Check if migration from previous version is faulty Object.keys(threadsById).forEach((thread) => {
if (!lastViewportIds?.every((id) => isLocalMessageId(id) || global.messages.byChatId[chatId]?.byId[id])) { const threadId = Number(thread);
global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'lastViewportIds', undefined); const lastViewportIds = selectThreadParam(global, chatId, threadId, 'lastViewportIds');
return; // Check if migration from previous version is faulty
} if (!lastViewportIds?.every((id) => isLocalMessageId(id) || global.messages.byChatId[chatId]?.byId[id])) {
global = replaceTabThreadParam( global = replaceThreadParam(global, chatId, threadId, 'lastViewportIds', undefined);
global, return;
chatId, }
MAIN_THREAD_ID, global = replaceTabThreadParam(
'viewportIds', global,
lastViewportIds, chatId,
tabId, threadId,
); 'viewportIds',
lastViewportIds,
tabId,
);
});
}); });
// Temporary state fix // Temporary state fix

View File

@ -30,6 +30,7 @@ import {
} from '../../util/iteratees'; } from '../../util/iteratees';
import { updateTabState } from './tabs'; import { updateTabState } from './tabs';
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
import { isLocalMessageId } from '../helpers';
type MessageStoreSections = { type MessageStoreSections = {
byId: Record<number, ApiMessage>; byId: Record<number, ApiMessage>;
@ -254,7 +255,7 @@ export function deleteChatMessages<T extends GlobalState>(
messageIds.forEach((messageId) => { messageIds.forEach((messageId) => {
if (listedIds?.includes(messageId)) { if (listedIds?.includes(messageId)) {
listedIds = listedIds.filter((id) => id !== messageId); listedIds = listedIds.filter((id) => id !== messageId);
if (newMessageCount !== undefined) newMessageCount -= 1; if (newMessageCount !== undefined && !isLocalMessageId(messageId)) newMessageCount -= 1;
} }
if (pinnedIds?.includes(messageId)) { if (pinnedIds?.includes(messageId)) {

View File

@ -482,8 +482,14 @@ export function selectThreadIdFromMessage<T extends GlobalState>(global: T, mess
return message.id; return message.id;
} }
// TODO ignore only basic group if reply threads are added if (!chat?.isForum) {
if (!chat?.isForum) return MAIN_THREAD_ID; if (chat && isChatBasicGroup(chat)) return MAIN_THREAD_ID;
if (chat && isChatSuperGroup(chat)) {
return replyToTopMessageId || replyToMessageId || MAIN_THREAD_ID;
}
return MAIN_THREAD_ID;
}
if (!isTopicReply) return GENERAL_TOPIC_ID; if (!isTopicReply) return GENERAL_TOPIC_ID;
return replyToTopMessageId || replyToMessageId || GENERAL_TOPIC_ID; return replyToTopMessageId || replyToMessageId || GENERAL_TOPIC_ID;
} }
@ -531,7 +537,10 @@ export function selectAllowedMessageActions<T extends GlobalState>(global: T, me
&& !chat.isForbidden && !chat.isForbidden
); );
const canReply = !isLocal && !isServiceNotification && !chat.isForbidden && getCanPostInChat(chat, threadId) const threadInfo = selectThreadInfo(global, message.chatId, threadId);
const isComments = Boolean(threadInfo?.originChannelId);
const canReply = !isLocal && !isServiceNotification && !chat.isForbidden
&& getCanPostInChat(chat, threadId, isComments)
&& (!messageTopic || !messageTopic.isClosed || messageTopic.isOwner || getHasAdminRight(chat, 'manageTopics')); && (!messageTopic || !messageTopic.isClosed || messageTopic.isOwner || getHasAdminRight(chat, 'manageTopics'));
const hasPinPermission = isPrivate || ( const hasPinPermission = isPrivate || (

View File

@ -1572,6 +1572,11 @@ export interface ActionPayloads {
shouldReplaceHistory?: boolean; shouldReplaceHistory?: boolean;
noForumTopicPanel?: boolean; noForumTopicPanel?: boolean;
} & WithTabId; } & WithTabId;
openComments: {
id: string;
threadId: number;
originChannelId?: string;
} & WithTabId;
loadFullChat: { loadFullChat: {
chatId: string; chatId: string;
force?: boolean; force?: boolean;

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,9 @@
.icon-volume-3:before { .icon-volume-3:before {
content: "\e991"; content: "\e991";
} }
.icon-replies:before {
content: "\e9b9";
}
.icon-forums:before { .icon-forums:before {
content: "\e9b4"; content: "\e9b4";
} }