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 }),
...(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 }),
isProtected,
isForwardingAllowed,
@ -1591,20 +1591,18 @@ function buildThreadInfo(
messageReplies: GramJs.TypeMessageReplies, messageId: number, chatId: string,
): ApiThreadInfo | undefined {
const {
channelId, replies, maxId, readMaxId, recentRepliers,
channelId, replies, maxId, readMaxId, recentRepliers, comments,
} = messageReplies;
if (!channelId) {
return undefined;
}
const apiChannelId = buildApiPeerId(channelId, 'channel');
const apiChannelId = channelId ? buildApiPeerId(channelId, 'channel') : undefined;
if (apiChannelId === DELETED_COMMENTS_CHANNEL_ID) {
return undefined;
}
const isPostThread = chatId !== apiChannelId;
const isPostThread = apiChannelId && chatId !== apiChannelId;
return {
isComments: comments,
threadId: messageId,
...(isPostThread ? {
chatId: apiChannelId,

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@ -40,7 +40,7 @@ import {
selectIsUserBlocked,
selectPinnedIds,
selectReplyingToId,
selectTheme,
selectTheme, selectThreadInfo,
} from '../../global/selectors';
import {
getCanPostInChat,
@ -630,7 +630,9 @@ export default memo(withGlobal<OwnProps>(
const pinnedIds = selectPinnedIds(global, chatId, threadId);
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 isPinnedMessageList = messageListType === 'pinned';
const isScheduledMessageList = messageListType === 'scheduled';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import {
selectIsChatWithBot,
selectCurrentMessageList,
selectCanScheduleUntilOnline,
selectIsChatWithSelf,
selectIsChatWithSelf, selectThreadInfo,
} from '../../global/selectors';
import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
@ -155,7 +155,9 @@ export default memo(withGlobal(
const chat = chatId ? selectChat(global, chatId) : undefined;
const isChatWithBot = chat ? selectIsChatWithBot(global, chat) : undefined;
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 {
query,

View File

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

View File

@ -55,7 +55,7 @@ import {
selectChatFolder, selectSupportChat, selectChatByUsername,
selectCurrentMessageList, selectThreadInfo, selectCurrentChat, selectLastServiceNotification,
selectVisibleUsers, selectUserByPhoneNumber, selectDraft, selectThreadTopMessageId,
selectTabState, selectThread,
selectTabState, selectThread, selectThreadOriginChat,
} from '../../selectors';
import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { debounce, pause, throttle } from '../../../util/schedulers';
@ -142,10 +142,38 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
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) {
const topMessageId = selectThreadTopMessageId(global, id, threadId);
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,
selectEditingId,
selectDraft,
selectThreadOriginChat,
selectThreadTopMessageId,
selectEditingScheduledId,
selectEditingMessage,
@ -239,12 +238,17 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
}
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 = {
...payload,
chat,
replyingTo: selectReplyingToId(global, chatId, threadId),
replyingTo: replyingToId,
replyingToTopId,
noWebPage: selectNoWebPage(global, chatId, threadId),
sendAs: selectSendAs(global, chatId),
@ -549,7 +553,7 @@ addActionHandler('markMessageListRead', (global, actions, payload): ActionReturn
}
const { chatId, threadId } = currentMessageList;
const chat = selectThreadOriginChat(global, chatId, threadId);
const chat = selectChat(global, chatId);
if (!chat) {
return undefined;
}
@ -853,7 +857,7 @@ addActionHandler('rescheduleMessage', (global, actions, payload): ActionReturnTy
addActionHandler('requestThreadInfoUpdate', async (global, actions, payload): Promise<void> => {
const { chatId, threadId } = payload;
const chat = selectThreadOriginChat(global, chatId, threadId);
const chat = selectChat(global, chatId);
if (!chat) {
return;
}
@ -939,7 +943,7 @@ async function loadViewportMessages<T extends GlobalState>(
global = getGlobal();
const result = await callApi('fetchMessages', {
chat: selectThreadOriginChat(global, chatId, threadId)!,
chat: selectChat(global, chatId)!,
offsetId,
addOffset,
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 threadInfo = currentThreadId && currentChatId
? selectThreadInfo(global, currentChatId, currentThreadId) : undefined;
// TODO Fix comments chat id, or refetch chat thread here
const activeCurrentChatId = threadInfo?.originChannelId || currentChatId;
// Memoize drafts
const draftChatIds = Object.keys(global.messages.byChatId);
// eslint-disable-next-line @typescript-eslint/no-loop-func
@ -119,14 +117,14 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
return acc;
}, {});
const currentChat = activeCurrentChatId ? global.chats.byId[activeCurrentChatId] : undefined;
if (activeCurrentChatId && currentChat) {
const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined;
if (currentChatId && currentChat) {
const result = await loadTopMessages(currentChat, activeThreadId, threadInfo?.lastReadInboxMessageId);
global = getGlobal();
const { chatId: newCurrentChatId } = selectCurrentMessageList(global, tabId) || {};
if (result && newCurrentChatId === currentChatId) {
const currentChatMessages = selectChatMessages(global, activeCurrentChatId);
const currentChatMessages = selectChatMessages(global, currentChatId);
const localMessages = currentChatId === SERVICE_NOTIFICATIONS_USER_ID
? global.serviceNotifications.filter(({ isDeleted }) => !isDeleted).map(({ message }) => message)
: [];
@ -158,18 +156,20 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
wasReset = true;
}
global = addChatMessagesById(global, activeCurrentChatId, byId);
global = updateListedIds(global, activeCurrentChatId, activeThreadId, listedIds);
global = addChatMessagesById(global, currentChatId, byId);
global = updateListedIds(global, currentChatId, activeThreadId, listedIds);
// eslint-disable-next-line @typescript-eslint/no-loop-func
Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
const { chatId: otherChatId, threadId: otherThreadId } = selectCurrentMessageList(global, otherTabId) || {};
if (otherChatId === activeCurrentChatId && otherThreadId === activeThreadId) {
global = safeReplaceViewportIds(global, activeCurrentChatId, activeThreadId, listedIds, otherTabId);
if (otherChatId === currentChatId && otherThreadId === activeThreadId) {
global = safeReplaceViewportIds(global, currentChatId, activeThreadId, listedIds, otherTabId);
}
});
global = updateChats(global, buildCollectionByKey(result.chats, '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;
}
@ -184,10 +184,10 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
setGlobal(global);
if (currentChat?.isForum) {
actions.loadTopics({ chatId: activeCurrentChatId!, force: true });
actions.loadTopics({ chatId: currentChatId!, force: true });
if (currentThreadId && currentThreadId !== MAIN_THREAD_ID) {
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 type { ApiUpdateChat } from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types';
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 {
updateChat,
@ -28,7 +29,15 @@ const TYPING_STATUS_CLEAR_DELAY = 6000; // 6 seconds
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
switch (update['@type']) {
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);
setGlobal(global);
@ -47,8 +56,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
Object.values(global.byTabId).forEach(({ id: tabId }) => {
const { chatId: currentChatId } = selectCurrentMessageList(global, tabId) || {};
const chatUpdate = update as ApiUpdateChat;
// 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) {
actions.closeForumPanel({ tabId });
}

View File

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

View File

@ -3,7 +3,7 @@ import { addCallback, removeCallback } from '../lib/teact/teactn';
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 { MAIN_THREAD_ID } from '../api/types';
@ -27,7 +27,7 @@ import {
} from '../util/iteratees';
import {
selectChat,
selectCurrentMessageList,
selectCurrentMessageList, selectThreadOriginChat,
selectVisibleUsers,
} from './selectors';
import { hasStoredSession } from '../util/sessions';
@ -459,8 +459,19 @@ function reduceChats<T extends GlobalState>(global: T): GlobalState['chats'] {
const { chats: { byId }, currentUserId } = global;
const currentChatIds = compact(
Object.values(global.byTabId)
.map(({ id: tabId }) => selectCurrentMessageList(global, tabId)),
).map(({ chatId }) => chatId).filter((chatId) => isUserId(chatId));
.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(({ chatId }) => chatId);
const idsToSave = unique([
...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 (chat.isForum) {
if (chat.isNotJoined) {
@ -189,10 +189,10 @@ export function getCanPostInChat(chat: ApiChat, threadId: number) {
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;
}

View File

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

View File

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

View File

@ -482,8 +482,14 @@ export function selectThreadIdFromMessage<T extends GlobalState>(global: T, mess
return message.id;
}
// TODO ignore only basic group if reply threads are added
if (!chat?.isForum) return MAIN_THREAD_ID;
if (!chat?.isForum) {
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;
return replyToTopMessageId || replyToMessageId || GENERAL_TOPIC_ID;
}
@ -531,7 +537,10 @@ export function selectAllowedMessageActions<T extends GlobalState>(global: T, me
&& !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'));
const hasPinPermission = isPrivate || (

View File

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

File diff suppressed because it is too large Load Diff

View File

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