From c3b850e6767b0210ebc9f49177cfbb6086930c75 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:37:54 +0100 Subject: [PATCH] Support Bot Forums (#6407) --- src/@types/global.d.ts | 1 - src/api/gramjs/apiBuilders/appConfig.ts | 2 + src/api/gramjs/apiBuilders/chats.ts | 4 +- src/api/gramjs/apiBuilders/messages.ts | 7 +- src/api/gramjs/apiBuilders/users.ts | 3 +- src/api/gramjs/methods/forum.ts | 7 +- src/api/gramjs/methods/messages.ts | 15 +- src/api/gramjs/updates/mtpUpdateHandler.ts | 25 ++- src/api/types/chats.ts | 1 + src/api/types/messages.ts | 23 +-- src/api/types/misc.ts | 1 + src/api/types/updates.ts | 14 +- src/api/types/users.ts | 1 + src/assets/font-icons/topic-new.svg | 1 + src/assets/localization/fallback.strings | 6 + src/components/common/Composer.tsx | 2 +- src/components/common/GroupChatInfo.tsx | 19 +- src/components/common/MessageText.tsx | 64 ++++--- src/components/common/PrivateChatInfo.tsx | 142 ++++++++++---- src/components/common/TypingWrapper.tsx | 71 +++++++ .../common/helpers/renderTextWithEntities.tsx | 12 +- src/components/left/ArchivedChats.tsx | 2 +- .../left/main/ChatBadge.module.scss | 2 +- src/components/left/main/LeftMain.tsx | 2 +- .../left/main/forum/AllMessagesTopic.tsx | 87 +++++++++ .../main/{ => forum}/EmptyForum.module.scss | 0 .../left/main/{ => forum}/EmptyForum.tsx | 42 +++-- .../main/{ => forum}/ForumPanel.module.scss | 0 .../left/main/{ => forum}/ForumPanel.tsx | 150 ++++++++------- .../left/main/{ => forum}/Topic.module.scss | 0 .../left/main/{ => forum}/Topic.tsx | 56 +++--- .../left/main/hooks/useTopicContextActions.ts | 5 +- src/components/middle/HeaderMenuContainer.tsx | 3 +- src/components/middle/MessageList.scss | 10 + src/components/middle/MessageList.tsx | 60 +++++- src/components/middle/MessageListContent.tsx | 18 ++ src/components/middle/MiddleHeader.tsx | 5 +- .../middle/composer/BotKeyboardMenu.tsx | 4 +- .../helpers/renderKeyboardButtonText.tsx | 4 +- .../middle/hooks/useMessageObservers.ts | 2 +- .../middle/message/ActionMessage.module.scss | 20 ++ .../middle/message/ActionMessage.tsx | 5 +- .../middle/message/ActionMessageText.tsx | 34 ++-- .../middle/message/InlineButtons.tsx | 23 ++- src/components/middle/message/Message.tsx | 176 +++++++++++------- .../middle/message/helpers/messageActions.tsx | 18 ++ src/components/ui/Transition.tsx | 19 +- src/config.ts | 1 + src/global/actions/api/chats.ts | 3 - src/global/actions/api/messages.ts | 44 ++++- src/global/actions/api/sync.ts | 3 +- src/global/actions/apiUpdaters/chats.ts | 19 +- src/global/actions/apiUpdaters/messages.ts | 90 ++++++++- src/global/actions/ui/messages.ts | 5 + src/global/cache.ts | 2 +- src/global/helpers/messages.ts | 44 ++++- src/global/reducers/messages.ts | 23 +++ src/global/reducers/peers.ts | 2 +- src/global/selectors/messages.ts | 5 +- src/global/types/actions.ts | 4 +- src/hooks/useResizeMessageObserver.ts | 53 ------ src/limits.ts | 1 + src/styles/icons.css | 73 ++++---- src/styles/icons.scss | 67 +++---- src/styles/icons.woff | Bin 37616 -> 37796 bytes src/styles/icons.woff2 | Bin 31348 -> 31628 bytes src/types/icons/font.ts | 1 + src/types/index.ts | 8 + src/types/language.d.ts | 6 + 69 files changed, 1124 insertions(+), 498 deletions(-) create mode 100644 src/assets/font-icons/topic-new.svg create mode 100644 src/components/common/TypingWrapper.tsx create mode 100644 src/components/left/main/forum/AllMessagesTopic.tsx rename src/components/left/main/{ => forum}/EmptyForum.module.scss (100%) rename src/components/left/main/{ => forum}/EmptyForum.tsx (52%) rename src/components/left/main/{ => forum}/ForumPanel.module.scss (100%) rename src/components/left/main/{ => forum}/ForumPanel.tsx (66%) rename src/components/left/main/{ => forum}/Topic.module.scss (100%) rename src/components/left/main/{ => forum}/Topic.tsx (81%) delete mode 100644 src/hooks/useResizeMessageObserver.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index faf3b4260..c112952cb 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -175,7 +175,6 @@ interface BooleanConstructor { interface Array { filter(predicate: BooleanConstructor, thisArg?: unknown): Exclude[]; - at(index: number): T; // Make it behave like arr[arr.length - 1] } interface ReadonlyArray { filter(predicate: BooleanConstructor, thisArg?: unknown): Exclude[]; diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 7cc39a647..0e3985689 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -118,6 +118,7 @@ export interface GramJsAppConfig extends LimitsConfig { verify_age_bot_username?: string; verify_age_country?: string; verify_age_min?: number; + message_typing_draft_ttl?: number; contact_note_length_limit?: number; } @@ -241,6 +242,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp verifyAgeBotUsername: appConfig.verify_age_bot_username, verifyAgeCountry: appConfig.verify_age_country, verifyAgeMin: appConfig.verify_age_min, + typingDraftTtl: appConfig.message_typing_draft_ttl, }; return { diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 12a1dd640..b1ba2cb9d 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -89,6 +89,7 @@ function buildApiChatFieldsFromPeerEntity( const emojiStatus = userOrChannel?.emojiStatus ? buildApiEmojiStatus(userOrChannel.emojiStatus) : undefined; const paidMessagesStars = userOrChannel?.sendPaidMessagesStars; const isVerified = userOrChannel?.verified; + const isForum = channel?.forum || user?.botForumView; return { isMin, @@ -113,7 +114,8 @@ function buildApiChatFieldsFromPeerEntity( profileColor, isJoinToSend: channel?.joinToSend, isJoinRequest: channel?.joinRequest, - isForum: channel?.forum, + isForum, + isBotForum: user?.botForumView, isMonoforum: channel?.monoforum, linkedMonoforumId: channel?.linkedMonoforumId !== undefined ? buildApiPeerId(channel.linkedMonoforumId, 'channel') : undefined, diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 399add47a..0d0bb2891 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -36,6 +36,7 @@ import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../types'; import { DELETED_COMMENTS_CHANNEL_ID, + LOCAL_MESSAGES_LIMIT, SERVICE_NOTIFICATIONS_USER_ID, SPONSORED_MESSAGE_CACHE_MS, SUPPORTED_AUDIO_CONTENT_TYPES, @@ -76,8 +77,6 @@ import { buildApiRestrictionReasons } from './misc'; import { buildApiPeerColor, buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildMessageReactions } from './reactions'; -const LOCAL_MESSAGES_LIMIT = 1e6; // 1M - const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp'; const INPUT_WAVEFORM_LENGTH = 63; const MIN_SCHEDULED_PERIOD = 10; @@ -87,6 +86,10 @@ function getNextLocalMessageId(lastMessageId = 0) { return lastMessageId + (++localMessageCounter / LOCAL_MESSAGES_LIMIT); } +export function incrementLocalMessageCounter() { + localMessageCounter++; +} + let currentUserId!: string; export function setMessageBuilderCurrentUserId(_currentUserId: string) { diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 3aaa35ae2..bebf58e7d 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -112,7 +112,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { const { id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId, bot, botActiveUsers, botVerificationIcon, botInlinePlaceholder, botAttachMenu, botCanEdit, - sendPaidMessagesStars, profileColor, + sendPaidMessagesStars, profileColor, botForumView, } = mtpUser; const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto ? Boolean(mtpUser.photo.hasVideo) : undefined; const avatarPhotoId = mtpUser.photo && buildAvatarPhotoId(mtpUser.photo); @@ -155,6 +155,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { color: mtpUser.color && buildApiPeerColor(mtpUser.color), profileColor: profileColor && buildApiPeerColor(profileColor), paidMessagesStars: toJSNumber(sendPaidMessagesStars), + isBotForum: botForumView, }; } diff --git a/src/api/gramjs/methods/forum.ts b/src/api/gramjs/methods/forum.ts index 1efe100db..5cd3660c6 100644 --- a/src/api/gramjs/methods/forum.ts +++ b/src/api/gramjs/methods/forum.ts @@ -16,13 +16,14 @@ import { processAffectedHistory } from '../updates/updateManager'; import { invokeRequest } from './client'; export async function createTopic({ - chat, title, iconColor, iconEmojiId, sendAs, + chat, title, iconColor, iconEmojiId, sendAs, isTitleMissing, }: { chat: ApiChat; title: string; iconColor?: number; iconEmojiId?: string; sendAs?: ApiPeer; + isTitleMissing?: true; }) { const { id, accessHash } = chat; @@ -33,6 +34,7 @@ export async function createTopic({ iconEmojiId: iconEmojiId ? BigInt(iconEmojiId) : undefined, sendAs: sendAs ? buildInputPeer(sendAs.id, sendAs.accessHash) : undefined, randomId: generateRandomBigInt(), + titleMissing: isTitleMissing, })); if (!(updates instanceof GramJs.Updates) || !updates.updates.length) { @@ -75,9 +77,10 @@ export async function fetchTopics({ if (!result) return undefined; - const { count, orderByCreateDate } = result; + const { orderByCreateDate } = result; const topics = result.topics.map(buildApiTopic).filter(Boolean); + const count = result.count === 0 ? topics.length : result.count; // Sometimes count is 0 in result, but we have topics const messages = result.messages.map(buildApiMessage).filter(Boolean); const draftsById = result.topics.reduce((acc, topic) => { if (topic instanceof GramJs.ForumTopic && topic.draft) { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index dd3e1aeb4..c6e658678 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -75,6 +75,7 @@ import { buildLocalMessage, buildPreparedInlineMessage, buildUploadingMedia, + incrementLocalMessageCounter, } from '../apiBuilders/messages'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { buildApiUser, buildApiUserStatuses } from '../apiBuilders/users'; @@ -1255,23 +1256,21 @@ export async function markMessageListRead({ }) { const isChannel = getEntityTypeById(chat.id) === 'channel'; - // Workaround for local message IDs overflowing some internal `Buffer` range check - const fixedMaxId = Math.min(maxId, MAX_INT_32); if (isChannel && threadId === MAIN_THREAD_ID) { await invokeRequest(new GramJs.channels.ReadHistory({ channel: buildInputChannel(chat.id, chat.accessHash), - maxId: fixedMaxId, + maxId, })); - } else if (isChannel) { + } else if (threadId !== MAIN_THREAD_ID) { await invokeRequest(new GramJs.messages.ReadDiscussion({ peer: buildInputPeer(chat.id, chat.accessHash), msgId: Number(threadId), - readMaxId: fixedMaxId, + readMaxId: maxId, })); } else { const result = await invokeRequest(new GramJs.messages.ReadHistory({ peer: buildInputPeer(chat.id, chat.accessHash), - maxId: fixedMaxId, + maxId, })); if (result) { @@ -2555,3 +2554,7 @@ export async function fetchPreparedInlineMessage({ return buildPreparedInlineMessage(result); } + +export function incrementLocalMessagesCounter() { + incrementLocalMessageCounter(); +} diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index 5304af2c5..b8d8aeb47 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -28,6 +28,7 @@ import { buildChatTypingStatus, } from '../apiBuilders/chats'; import { + buildApiFormattedText, buildApiPhoto, buildApiUsernames, buildPrivacyRules, } from '../apiBuilders/common'; import { omitVirtualClassFields } from '../apiBuilders/helpers'; @@ -496,10 +497,9 @@ export function updater(update: Update) { sendApiUpdate({ '@type': 'updateChatInbox', id: getApiChatIdFromMtpPeer(update.peer), - chat: { - lastReadInboxMessageId: update.maxId, - unreadCount: update.stillUnreadCount, - }, + lastReadInboxMessageId: update.maxId, + unreadCount: update.stillUnreadCount, + threadId: update.topMsgId, }); } else if (update instanceof GramJs.UpdateReadHistoryOutbox) { sendApiUpdate({ @@ -648,22 +648,33 @@ export function updater(update: Update) { update instanceof GramJs.UpdateUserTyping || update instanceof GramJs.UpdateChatUserTyping ) { - const id = update instanceof GramJs.UpdateUserTyping + const chatId = update instanceof GramJs.UpdateUserTyping ? buildApiPeerId(update.userId, 'user') : buildApiPeerId(update.chatId, 'chat'); + const threadId = update instanceof GramJs.UpdateUserTyping ? update.topMsgId : undefined; + if (update.action instanceof GramJs.SendMessageEmojiInteraction) { sendApiUpdate({ '@type': 'updateStartEmojiInteraction', - id, + id: chatId, emoji: update.action.emoticon, messageId: update.action.msgId, interaction: buildApiEmojiInteraction(JSON.parse(update.action.interaction.data)), }); + } else if (update.action instanceof GramJs.SendMessageTextDraftAction) { + sendApiUpdate({ + '@type': 'updateChatTypingDraft', + chatId, + id: update.action.randomId.toString(), + threadId, + text: buildApiFormattedText(update.action.text), + }); } else { sendApiUpdate({ '@type': 'updateChatTypingStatus', - id, + id: chatId, + threadId, typingStatus: buildChatTypingStatus(update), }); } diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 65be0a1fa..ab58d2f43 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -57,6 +57,7 @@ export interface ApiChat { isForum?: boolean; isForumAsMessages?: true; isMonoforum?: boolean; + isBotForum?: boolean; withForumTabs?: boolean; linkedMonoforumId?: string; areChannelMessagesAllowed?: boolean; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 508d3575c..d4d7a1242 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -489,7 +489,7 @@ export type ApiMessageEntityDefault = { type: Exclude< `${ApiMessageEntityTypes}`, `${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` | - `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.Timestamp}` + `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Timestamp}` >; offset: number; length: number; @@ -538,15 +538,8 @@ export type ApiMessageEntityTimestamp = { timestamp: number; }; -export type ApiMessageEntityQuoteFocus = { - type: 'quoteFocus'; - offset: number; - length: number; -}; - export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl | - ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp | - ApiMessageEntityQuoteFocus; + ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp; export enum ApiMessageEntityTypes { Bold = 'MessageEntityBold', @@ -683,6 +676,8 @@ export interface ApiMessage { reportDeliveryUntilDate?: number; paidMessageStars?: number; restrictionReasons?: ApiRestrictionReason[]; + + isTypingDraft?: boolean; // Local field } export interface ApiReactions { @@ -922,13 +917,18 @@ interface ApiKeyboardButtonCopy { copyText: string; } -export interface ApiKeyboardButtonSuggestedMessage { +export interface KeyboardButtonSuggestedMessage { type: 'suggestedMessage'; text: string; buttonType: 'approve' | 'decline' | 'suggestChanges'; disabled?: boolean; } +export interface KeyboardButtonOpenThread { + type: 'openThread'; + text: string; +} + export type ApiKeyboardButton = ( ApiKeyboardButtonSimple | ApiKeyboardButtonReceipt @@ -941,7 +941,8 @@ export type ApiKeyboardButton = ( | ApiKeyboardButtonSimpleWebView | ApiKeyboardButtonUrlAuth | ApiKeyboardButtonCopy - | ApiKeyboardButtonSuggestedMessage + | KeyboardButtonSuggestedMessage + | KeyboardButtonOpenThread ); export type ApiKeyboardButtons = ApiKeyboardButton[][]; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 26c4a5197..3f408acf3 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -274,6 +274,7 @@ export interface ApiAppConfig { verifyAgeBotUsername?: string; verifyAgeCountry?: string; verifyAgeMin?: number; + typingDraftTtl: number; contactNoteLimit?: number; } diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 980dc2144..c87264adc 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -132,7 +132,9 @@ export type ApiUpdateChatLeave = { export type ApiUpdateChatInbox = { '@type': 'updateChatInbox'; id: string; - chat: Partial; + threadId?: ThreadId; + lastReadInboxMessageId: number; + unreadCount: number; }; export type ApiUpdateChatTypingStatus = { @@ -142,6 +144,14 @@ export type ApiUpdateChatTypingStatus = { typingStatus: ApiTypingStatus | undefined; }; +export type ApiUpdateChatTypingDraft = { + '@type': 'updateChatTypingDraft'; + chatId: string; + id: string; + threadId?: ThreadId; + text: ApiFormattedText; +}; + export type ApiUpdateStartEmojiInteraction = { '@type': 'updateStartEmojiInteraction'; id: string; @@ -878,7 +888,7 @@ export type ApiUpdate = ( ApiUpdateRecentStickers | ApiUpdateSavedGifs | ApiUpdateNewScheduledMessage | ApiUpdateMoveStickerSetToTop | ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | ApiUpdateStarPaymentStateCompleted | ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages | ApiUpdateMessageTranslations | - ApiUpdateFailedMessageTranslations | ApiUpdateWebPage | + ApiUpdateFailedMessageTranslations | ApiUpdateWebPage | ApiUpdateChatTypingDraft | ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode | ApiUpdateWebViewResultSent | ApiUpdateDefaultNotifySettings | ApiUpdatePeerNotifySettings | ApiUpdatePeerBlocked | ApiUpdatePrivacy | ApiUpdateServerTimeOffset | ApiUpdateMessageReactions | ApiUpdateSavedReactionTags | diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 268d88dd9..797c9f071 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -47,6 +47,7 @@ export interface ApiUser { botActiveUsers?: number; botVerificationIconId?: string; paidMessagesStars?: number; + isBotForum?: boolean; } export interface ApiUserFullInfo { diff --git a/src/assets/font-icons/topic-new.svg b/src/assets/font-icons/topic-new.svg new file mode 100644 index 000000000..00142c166 --- /dev/null +++ b/src/assets/font-icons/topic-new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 711bee245..58fae90f4 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2301,6 +2301,10 @@ "TitleGiftLocked" = "Gift Locked"; "GiftLockedMessage" = "This gift is currently only available to earlier Telegram users. It will unlock for your account in about **{relativeDate}**."; "QuickPreview" = "Quick Preview"; +"BotForumContinueThreadButton" = "Continue Last Thread"; +"BotForumActionNew" = "New Thread"; +"BotForumActionNewDescription" = "Type any message to create a new thread."; +"BotForumTopicTitlePlaceholder" = "New Thread"; "DropOriginalDetailsTransaction" = "Removed Gift Description"; "StarGiftReasonDropOriginalDetails" = "Removed Description"; "GiftAnUpgradeButton" = "Gift an Upgrade"; @@ -2310,6 +2314,8 @@ "UserNoteTitle" = "Notes"; "UserNoteHint" = "only visible to you"; "EditUserNoteHint" = "Notes are only visible to you."; +"BotForumAllTopicTitle" = "All Messages"; +"BotForumAllTopicDescription" = "All messages from all topics"; "AriaStoryTogglerOpen" = "Open Story List"; "FileTransferProgress" = "{currentSize} / {totalSize}"; "MediaSizeB_one" = "{size}B"; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 6a28db9e9..36e0ac2cb 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -1705,7 +1705,7 @@ const Composer: FC = ({ return lang('ComposerPlaceholderAnonymous'); } - if (chat?.isForum && chat?.isForumAsMessages && threadId === MAIN_THREAD_ID) { + if (chat?.isForum && !chat.isBotForum && chat.isForumAsMessages && threadId === MAIN_THREAD_ID) { return replyToTopic ? lang('ComposerPlaceholderTopic', { topic: replyToTopic.title }) : lang('ComposerPlaceholderTopicGeneral'); diff --git a/src/components/common/GroupChatInfo.tsx b/src/components/common/GroupChatInfo.tsx index 636c63341..d9deed25b 100644 --- a/src/components/common/GroupChatInfo.tsx +++ b/src/components/common/GroupChatInfo.tsx @@ -1,10 +1,8 @@ -import type { FC } from '../../lib/teact/teact'; -import type React from '../../lib/teact/teact'; import { memo, useEffect, useMemo } from '../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../global'; import type { - ApiChat, ApiThreadInfo, ApiTopic, ApiTypingStatus, ApiUser, + ApiChat, ApiTopic, ApiTypingStatus, ApiUser, } from '../../api/types'; import type { IconName } from '../../types/icons'; import { MediaViewerOrigin, type StoryViewerOrigin, type ThreadId } from '../../types'; @@ -21,7 +19,6 @@ import { selectChatOnlineCount, selectIsChatRestricted, selectMonoforumChannel, - selectThreadInfo, selectThreadMessagesCount, selectTopic, selectUser, @@ -68,12 +65,11 @@ type OwnProps = { isSavedDialog?: boolean; withMonoforumStatus?: boolean; onClick?: VoidFunction; - onEmojiStatusClick?: NoneToVoidFunction; + onEmojiStatusClick?: VoidFunction; }; type StateProps = { chat?: ApiChat; - threadInfo?: ApiThreadInfo; topic?: ApiTopic; onlineCount?: number; areMessagesLoaded: boolean; @@ -82,7 +78,7 @@ type StateProps = { monoforumChannel?: ApiChat; }; -const GroupChatInfo: FC = ({ +const GroupChatInfo = ({ typingStatus, className, statusIcon, @@ -95,7 +91,6 @@ const GroupChatInfo: FC = ({ withFullInfo, withUpdatingStatus, withChatType, - threadInfo, noRtl, chat: realChat, onlineCount, @@ -113,7 +108,7 @@ const GroupChatInfo: FC = ({ monoforumChannel, onClick, onEmojiStatusClick, -}) => { +}: OwnProps & StateProps) => { const { loadFullChat, openMediaViewer, @@ -126,7 +121,7 @@ const GroupChatInfo: FC = ({ const lang = useLang(); const isSuperGroup = chat && isChatSuperGroup(chat); - const isTopic = Boolean(chat?.isForum && threadInfo && topic); + const isTopic = Boolean(chat?.isForum && topic); const { id: chatId, isMin } = chat || {}; const isRestricted = selectIsChatRestricted(getGlobal(), chatId!); @@ -204,7 +199,7 @@ const GroupChatInfo: FC = ({ activeKey={messagesCount !== undefined ? 1 : 2} className="message-count-transition" > - {messagesCount !== undefined && oldLang('messages', messagesCount, 'i')} + {messagesCount !== undefined ? oldLang('messages', messagesCount, 'i') : oldLang('lng_forum_no_messages')} ); @@ -290,7 +285,6 @@ const GroupChatInfo: FC = ({ export default memo(withGlobal( (global, { chatId, threadId }): Complete => { const chat = selectChat(global, chatId); - const threadInfo = threadId ? selectThreadInfo(global, chatId, threadId) : undefined; const onlineCount = chat ? selectChatOnlineCount(global, chat) : undefined; const areMessagesLoaded = Boolean(selectChatMessages(global, chatId)); const topic = threadId ? selectTopic(global, chatId, threadId) : undefined; @@ -300,7 +294,6 @@ export default memo(withGlobal( return { chat, - threadInfo, onlineCount, topic, areMessagesLoaded, diff --git a/src/components/common/MessageText.tsx b/src/components/common/MessageText.tsx index ee80dc79e..8d31a76fa 100644 --- a/src/components/common/MessageText.tsx +++ b/src/components/common/MessageText.tsx @@ -12,9 +12,12 @@ import trimText from '../../util/trimText'; import { insertTextEntity, renderTextWithEntities } from './helpers/renderTextWithEntities'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import useSyncEffect from '../../hooks/useSyncEffect'; import useUniqueId from '../../hooks/useUniqueId'; +import TypingWrapper from './TypingWrapper'; + interface OwnProps { messageOrStory: ApiMessage | ApiStory; threadId?: ThreadId; @@ -36,6 +39,7 @@ interface OwnProps { isInSelectMode?: boolean; canBeEmpty?: boolean; maxTimestamp?: number; + shouldAnimateTyping?: boolean; } const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; @@ -61,6 +65,7 @@ function MessageText({ canBeEmpty, maxTimestamp, threadId, + shouldAnimateTyping, }: OwnProps) { const sharedCanvasRef = useRef(); const sharedCanvasHqRef = useRef(); @@ -107,37 +112,48 @@ function MessageText({ return customEmojisCount >= MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS; }, [entitiesWithFocusedQuote]) || 0; + const renderText = useLastCallback((t: ApiFormattedText) => { + return renderTextWithEntities({ + text: t.text, + entities: t.entities, + highlight, + emojiSize, + shouldRenderAsHtml, + containerId, + asPreview, + isProtected, + observeIntersectionForLoading, + observeIntersectionForPlaying, + withTranslucentThumbs, + sharedCanvasRef, + sharedCanvasHqRef, + cacheBuster: textCacheBusterRef.current.toString(), + forcePlayback, + isInSelectMode, + maxTimestamp, + chatId: 'chatId' in messageOrStory ? messageOrStory.chatId : undefined, + messageId: messageOrStory.id, + threadId, + }); + }); + if (!text && !canBeEmpty) { return {lang('MessageUnsupported')}; } + const textToRender: ApiFormattedText = { + text: trimText(text || '', truncateLength), + entities: entitiesWithFocusedQuote, + }; + return ( <> {[ - withSharedCanvas && , - withSharedCanvas && , - renderTextWithEntities({ - text: trimText(text!, truncateLength), - entities: entitiesWithFocusedQuote, - highlight, - emojiSize, - shouldRenderAsHtml, - containerId, - asPreview, - isProtected, - observeIntersectionForLoading, - observeIntersectionForPlaying, - withTranslucentThumbs, - sharedCanvasRef, - sharedCanvasHqRef, - cacheBuster: textCacheBusterRef.current.toString(), - forcePlayback, - isInSelectMode, - maxTimestamp, - chatId: 'chatId' in messageOrStory ? messageOrStory.chatId : undefined, - messageId: messageOrStory.id, - threadId, - }), + withSharedCanvas && , + withSharedCanvas && , + shouldAnimateTyping ? ( + {renderText} + ) : renderText(textToRender), ].flat().filter(Boolean)} ); diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index f58b8e565..f053c35f9 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -1,19 +1,25 @@ -import type { FC } from '../../lib/teact/teact'; import { memo, useEffect, useMemo } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { - ApiChatMember, ApiTypingStatus, ApiUser, ApiUserStatus, + ApiChatMember, ApiTopic, ApiTypingStatus, ApiUser, ApiUserStatus, } from '../../api/types'; -import type { CustomPeer, StoryViewerOrigin } from '../../types'; +import type { CustomPeer, StoryViewerOrigin, ThreadId } from '../../types'; import type { IconName } from '../../types/icons'; import { MediaViewerOrigin } from '../../types'; import { getMainUsername, getUserStatus, isSystemBot, isUserOnline, } from '../../global/helpers'; -import { selectChatMessages, selectUser, selectUserStatus } from '../../global/selectors'; +import { + selectChatMessages, + selectThreadMessagesCount, + selectTopic, + selectUser, + selectUserStatus, +} from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; +import { REM } from './helpers/mediaDimensions'; import renderText from './helpers/renderText'; import useIntervalForceUpdate from '../../hooks/schedulers/useIntervalForceUpdate'; @@ -22,15 +28,17 @@ import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; import RippleEffect from '../ui/RippleEffect'; +import Transition from '../ui/Transition'; import Avatar from './Avatar'; import DotAnimation from './DotAnimation'; import FullNameTitle from './FullNameTitle'; import Icon from './icons/Icon'; +import TopicIcon from './TopicIcon'; import TypingStatus from './TypingStatus'; -type OwnProps = { - userId?: string; - customPeer?: CustomPeer; +const TOPIC_ICON_SIZE = 2.5 * REM; + +type BaseOwnProps = { typingStatus?: ApiTypingStatus; avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo'; forceShowSelf?: boolean; @@ -52,25 +60,39 @@ type OwnProps = { noRtl?: boolean; adminMember?: ApiChatMember; isSavedDialog?: boolean; + noAvatar?: boolean; className?: string; - onEmojiStatusClick?: NoneToVoidFunction; iconElement?: React.ReactNode; rightElement?: React.ReactNode; + onClick?: VoidFunction; + onEmojiStatusClick?: VoidFunction; }; -type StateProps = - { - user?: ApiUser; - userStatus?: ApiUserStatus; - self?: ApiUser; - isSavedMessages?: boolean; - areMessagesLoaded: boolean; - isSynced?: boolean; - }; +type OwnProps = BaseOwnProps & ({ + userId: string; + threadId?: ThreadId; + customPeer?: never; +} | { + userId?: never; + threadId?: never; + customPeer: CustomPeer; +}); + +type StateProps = { + user?: ApiUser; + userStatus?: ApiUserStatus; + self?: ApiUser; + isSavedMessages?: boolean; + areMessagesLoaded: boolean; + isSynced?: boolean; + topic?: ApiTopic; + messagesCount?: number; +}; const UPDATE_INTERVAL = 1000 * 60; // 1 min -const PrivateChatInfo: FC = ({ +const PrivateChatInfo = ({ + userId, customPeer, typingStatus, avatarSize = 'medium', @@ -91,6 +113,8 @@ const PrivateChatInfo: FC = ({ user, userStatus, self, + topic, + messagesCount, isSavedMessages, isSavedDialog, areMessagesLoaded, @@ -98,11 +122,13 @@ const PrivateChatInfo: FC = ({ ripple, className, storyViewerOrigin, + noAvatar, isSynced, - onEmojiStatusClick, iconElement, rightElement, -}) => { + onClick, + onEmojiStatusClick, +}: OwnProps & StateProps) => { const { loadFullUser, openMediaViewer, @@ -112,8 +138,7 @@ const PrivateChatInfo: FC = ({ const oldLang = useOldLang(); const lang = useLang(); - const { id: userId } = user || {}; - + const isTopic = Boolean(user?.isBotForum && topic); const hasAvatarMediaViewer = withMediaViewer && !isSavedMessages; useEffect(() => { @@ -127,11 +152,11 @@ const PrivateChatInfo: FC = ({ const handleAvatarViewerOpen = useLastCallback( (e: React.MouseEvent, hasMedia: boolean) => { - if (user && hasMedia) { + if (hasMedia) { e.stopPropagation(); openMediaViewer({ isAvatarView: true, - chatId: user.id, + chatId: userId, mediaIndex: 0, origin: avatarSize === 'jumbo' ? MediaViewerOrigin.ProfileAvatar : MediaViewerOrigin.MiddleHeaderAvatar, }); @@ -179,6 +204,21 @@ const PrivateChatInfo: FC = ({ return ; } + if (isTopic) { + return ( + + + {messagesCount !== undefined ? oldLang('messages', messagesCount, 'i') : oldLang('lng_forum_no_messages')} + + + ); + } + if (isSystemBot(user.id)) { return undefined; } @@ -198,6 +238,12 @@ const PrivateChatInfo: FC = ({ : undefined; function renderNameTitle() { + if (isTopic) { + return ( +

{renderText(topic!.title)}

+ ); + } + if (customTitle) { return (
@@ -230,7 +276,11 @@ const PrivateChatInfo: FC = ({ } return ( -
+
{isSavedDialog && self && ( = ({ className="saved-dialog-avatar" /> )} - + {!noAvatar && !isTopic && ( + + )} + {isTopic && ( + + )}
{renderNameTitle()} {(status || (!isSavedMessages && !noStatusOrTyping)) && renderStatusOrTyping()} @@ -263,13 +322,16 @@ const PrivateChatInfo: FC = ({ }; export default memo(withGlobal( - (global, { userId, forceShowSelf }): Complete => { + (global, { userId, threadId, forceShowSelf }): Complete => { const { isSynced } = global; const user = userId ? selectUser(global, userId) : undefined; const userStatus = userId ? selectUserStatus(global, userId) : undefined; const isSavedMessages = !forceShowSelf && user && user.isSelf; const self = isSavedMessages ? user : selectUser(global, global.currentUserId!); - const areMessagesLoaded = Boolean(userId && selectChatMessages(global, userId)); + const areMessagesLoaded = Boolean(userId ? selectChatMessages(global, userId) : undefined); + + const topic = threadId ? selectTopic(global, userId, threadId) : undefined; + const messagesCount = topic && userId ? selectThreadMessagesCount(global, userId, threadId!) : undefined; return { user, @@ -278,6 +340,8 @@ export default memo(withGlobal( areMessagesLoaded, self, isSynced, + topic, + messagesCount, }; }, )(PrivateChatInfo)); diff --git a/src/components/common/TypingWrapper.tsx b/src/components/common/TypingWrapper.tsx new file mode 100644 index 000000000..074ccfeb6 --- /dev/null +++ b/src/components/common/TypingWrapper.tsx @@ -0,0 +1,71 @@ +import { + memo, useEffect, useRef, useSignal, useUnmountCleanup, +} from '../../lib/teact/teact'; + +import { + type ApiFormattedText, +} from '../../api/types'; + +import useDerivedState from '../../hooks/useDerivedState'; +import useLastCallback from '../../hooks/useLastCallback'; + +type OwnProps = { + text: ApiFormattedText; + duration?: number; + children: (text: ApiFormattedText) => React.ReactNode; +}; + +const DEFAULT_HEADWAY_DURATION = 1000; +const MIN_TIMEOUT_DURATION = 1000 / 60; // 60 FPS +const MAX_SYMBOLS_BATCH = 10; + +const TypingWrapper = ({ + text, + duration = DEFAULT_HEADWAY_DURATION, + children, +}: OwnProps) => { + const [getCurrentTextLength, setCurrentTextLength] = useSignal(text.text.length); + const intervalRef = useRef(); + + const animate = useLastCallback(() => { + const msPerSymbol = duration / text.text.length; + const timeoutDuration = Math.max(msPerSymbol, MIN_TIMEOUT_DURATION); + const nextSymbolBatchLength = Math.min(Math.ceil(timeoutDuration / msPerSymbol), MAX_SYMBOLS_BATCH); + + intervalRef.current = window.setTimeout(() => { + if (getCurrentTextLength() >= text.text.length) { + clearTimeout(intervalRef.current); + return; + } + + setCurrentTextLength(getCurrentTextLength() + nextSymbolBatchLength); + }, timeoutDuration); + }); + + useEffect(() => { + // Text got shorter, skip animation + if (text.text.length < getCurrentTextLength()) { + clearTimeout(intervalRef.current); + setCurrentTextLength(text.text.length); + return; + } + + clearTimeout(intervalRef.current); + animate(); + }, [getCurrentTextLength, setCurrentTextLength, text.text.length]); + + useUnmountCleanup(() => { + clearTimeout(intervalRef.current); + }); + + const displayedText = useDerivedState(() => { + return { + ...text, + text: text.text.slice(0, getCurrentTextLength()), + }; + }, [getCurrentTextLength, text]); + + return children(displayedText); +}; + +export default memo(TypingWrapper); diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index 177f92c3f..8f2224c3b 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -1,5 +1,4 @@ import type { ElementRef } from '../../../lib/teact/teact'; -import type React from '../../../lib/teact/teact'; import { getActions } from '../../../global'; import type { ApiFormattedText, ApiMessageEntity } from '../../../api/types'; @@ -10,7 +9,6 @@ import { ApiMessageEntityTypes } from '../../../api/types'; import buildClassName from '../../../util/buildClassName'; import { copyTextToClipboard } from '../../../util/clipboard'; -import { oldTranslate } from '../../../util/oldLangProvider'; import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji'; import renderText from './renderText'; @@ -301,6 +299,12 @@ function renderMessagePart({ return renderText(content, filters, params); } +export function insertTextEntities(entities: ApiMessageEntity[], newEntities: ApiMessageEntity[]) { + return newEntities.reduce((acc, newEntity) => { + return insertTextEntity(acc, newEntity); + }, entities); +} + export function insertTextEntity(entities: ApiMessageEntity[], newEntity: ApiMessageEntity) { const resultEntities: ApiMessageEntity[] = []; @@ -761,7 +765,9 @@ function handleHashtagClick(hashtag?: string, username?: string) { function handleCodeClick(e: React.MouseEvent) { copyTextToClipboard(e.currentTarget.innerText); getActions().showNotification({ - message: oldTranslate('TextCopied'), + message: { + key: 'TextCopied', + }, }); } diff --git a/src/components/left/ArchivedChats.tsx b/src/components/left/ArchivedChats.tsx index b047a1061..0a054cf8c 100644 --- a/src/components/left/ArchivedChats.tsx +++ b/src/components/left/ArchivedChats.tsx @@ -23,7 +23,7 @@ import Button from '../ui/Button'; import DropdownMenu from '../ui/DropdownMenu'; import MenuItem from '../ui/MenuItem'; import ChatList from './main/ChatList'; -import ForumPanel from './main/ForumPanel'; +import ForumPanel from './main/forum/ForumPanel'; import './ArchivedChats.scss'; diff --git a/src/components/left/main/ChatBadge.module.scss b/src/components/left/main/ChatBadge.module.scss index 494d104fe..a5a876919 100644 --- a/src/components/left/main/ChatBadge.module.scss +++ b/src/components/left/main/ChatBadge.module.scss @@ -103,7 +103,7 @@ font-size: 0.875rem !important; } -.selected { +.selected:not(.onAvatar) { .badge:not(.pinned) { color: var(--color-chat-active); background: var(--color-white); diff --git a/src/components/left/main/LeftMain.tsx b/src/components/left/main/LeftMain.tsx index dbbe8c89c..84b0caef5 100644 --- a/src/components/left/main/LeftMain.tsx +++ b/src/components/left/main/LeftMain.tsx @@ -25,7 +25,7 @@ import NewChatButton from '../NewChatButton'; import LeftSearch from '../search/LeftSearch.async'; import ChatFolders from './ChatFolders'; import ContactList from './ContactList.async'; -import ForumPanel from './ForumPanel'; +import ForumPanel from './forum/ForumPanel'; import LeftMainHeader from './LeftMainHeader'; import './LeftMain.scss'; diff --git a/src/components/left/main/forum/AllMessagesTopic.tsx b/src/components/left/main/forum/AllMessagesTopic.tsx new file mode 100644 index 000000000..3fc7d3292 --- /dev/null +++ b/src/components/left/main/forum/AllMessagesTopic.tsx @@ -0,0 +1,87 @@ +import { memo } from '@teact'; +import { getActions, withGlobal } from '../../../../global'; + +import { type ApiMessage, MAIN_THREAD_ID } from '../../../../api/types'; + +import { selectChatLastMessage } from '../../../../global/selectors'; +import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment'; +import buildClassName from '../../../../util/buildClassName'; +import { createLocationHash } from '../../../../util/routing'; + +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import LastMessageMeta from '../../../common/LastMessageMeta'; +import ListItem from '../../../ui/ListItem'; + +import styles from './Topic.module.scss'; + +type OwnProps = { + chatId: string; + isSelected: boolean; + style?: string; +}; + +type StateProps = { + lastMessage?: ApiMessage; +}; + +const AllMessagesTopic = ({ + chatId, isSelected, style, lastMessage, +}: OwnProps & StateProps) => { + const { openThread, openQuickPreview } = getActions(); + + const lang = useLang(); + + const handleOpenTopic = useLastCallback((e: React.MouseEvent) => { + if (e.altKey) { + e.preventDefault(); + openQuickPreview({ id: chatId }); + return; + } + + openThread({ chatId, threadId: MAIN_THREAD_ID, shouldReplaceHistory: true }); + }); + + return ( + +
+
+
+

{lang('BotForumAllTopicTitle')}

+
+
+ {lastMessage && ( + + )} +
+
+ + {lang('BotForumAllTopicDescription')} + +
+
+ + ); +}; + +export default memo(withGlobal( + (global, { chatId }): Complete => { + const lastMessage = selectChatLastMessage(global, chatId, 'all'); + return { + lastMessage, + }; + }, +)(AllMessagesTopic)); diff --git a/src/components/left/main/EmptyForum.module.scss b/src/components/left/main/forum/EmptyForum.module.scss similarity index 100% rename from src/components/left/main/EmptyForum.module.scss rename to src/components/left/main/forum/EmptyForum.module.scss diff --git a/src/components/left/main/EmptyForum.tsx b/src/components/left/main/forum/EmptyForum.tsx similarity index 52% rename from src/components/left/main/EmptyForum.tsx rename to src/components/left/main/forum/EmptyForum.tsx index 9b43f3872..b4680e810 100644 --- a/src/components/left/main/EmptyForum.tsx +++ b/src/components/left/main/forum/EmptyForum.tsx @@ -1,19 +1,20 @@ -import type { FC } from '../../../lib/teact/teact'; -import { memo, useCallback } from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; +import { memo } from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; -import type { ApiSticker } from '../../../api/types'; +import type { ApiSticker } from '../../../../api/types'; -import { getHasAdminRight } from '../../../global/helpers'; -import { selectAnimatedEmoji, selectChat } from '../../../global/selectors'; -import buildClassName from '../../../util/buildClassName'; -import { REM } from '../../common/helpers/mediaDimensions'; +import { getHasAdminRight } from '../../../../global/helpers'; +import { selectAnimatedEmoji, selectChat } from '../../../../global/selectors'; +import buildClassName from '../../../../util/buildClassName'; +import { REM } from '../../../common/helpers/mediaDimensions'; -import useAppLayout from '../../../hooks/useAppLayout'; -import useOldLang from '../../../hooks/useOldLang'; +import useAppLayout from '../../../../hooks/useAppLayout'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import useOldLang from '../../../../hooks/useOldLang'; -import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker'; -import Button from '../../ui/Button'; +import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker'; +import Button from '../../../ui/Button'; import styles from './EmptyForum.module.scss'; @@ -28,26 +29,27 @@ type StateProps = { const ICON_SIZE = 7 * REM; -const EmptyForum: FC = ({ +const EmptyForum = ({ chatId, animatedEmoji, canManageTopics, -}) => { +}: OwnProps & StateProps) => { const { openCreateTopicPanel } = getActions(); - const lang = useOldLang(); + const lang = useLang(); + const oldLang = useOldLang(); const { isMobile } = useAppLayout(); - const handleCreateTopic = useCallback(() => { + const handleCreateTopic = useLastCallback(() => { openCreateTopicPanel({ chatId }); - }, [chatId, openCreateTopicPanel]); + }); return (
{animatedEmoji && }
-

{lang('ChatList.EmptyTopicsTitle')}

+

{oldLang('ChatList.EmptyTopicsTitle')}

- {lang('ChatList.EmptyTopicsDescription')} + {oldLang('ChatList.EmptyTopicsDescription')}

{canManageTopics && ( )} diff --git a/src/components/left/main/ForumPanel.module.scss b/src/components/left/main/forum/ForumPanel.module.scss similarity index 100% rename from src/components/left/main/ForumPanel.module.scss rename to src/components/left/main/forum/ForumPanel.module.scss diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/forum/ForumPanel.tsx similarity index 66% rename from src/components/left/main/ForumPanel.tsx rename to src/components/left/main/forum/ForumPanel.tsx index 9a876925d..e3c447938 100644 --- a/src/components/left/main/ForumPanel.tsx +++ b/src/components/left/main/forum/ForumPanel.tsx @@ -1,19 +1,18 @@ -import type { FC } from '../../../lib/teact/teact'; import { beginHeavyAnimation, memo, useEffect, useMemo, useRef, useState, -} from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; +} from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; -import type { ApiChat } from '../../../api/types'; -import type { TopicsInfo } from '../../../types'; -import { MAIN_THREAD_ID } from '../../../api/types'; +import type { ApiChat } from '../../../../api/types'; +import type { TopicsInfo } from '../../../../types'; +import { MAIN_THREAD_ID } from '../../../../api/types'; import { GENERAL_TOPIC_ID, TOPIC_HEIGHT_PX, TOPIC_LIST_SENSITIVE_AREA, TOPICS_SLICE, -} from '../../../config'; -import { requestNextMutation } from '../../../lib/fasterdom/fasterdom'; -import { getOrderedTopics } from '../../../global/helpers'; +} from '../../../../config'; +import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom'; +import { getOrderedTopics } from '../../../../global/helpers'; import { selectCanAnimateInterface, selectChat, @@ -21,29 +20,32 @@ import { selectIsForumPanelOpen, selectTabState, selectTopicsInfo, -} from '../../../global/selectors'; -import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment'; -import buildClassName from '../../../util/buildClassName'; -import captureEscKeyListener from '../../../util/captureEscKeyListener'; -import { captureEvents, SwipeDirection } from '../../../util/captureEvents'; -import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners'; +} from '../../../../global/selectors'; +import { IS_TOUCH_ENV } from '../../../../util/browser/windowEnvironment'; +import buildClassName from '../../../../util/buildClassName'; +import captureEscKeyListener from '../../../../util/captureEscKeyListener'; +import { captureEvents, SwipeDirection } from '../../../../util/captureEvents'; +import { waitForTransitionEnd } from '../../../../util/cssAnimationEndListeners'; +import { isUserId } from '../../../../util/entities/ids'; -import useAppLayout from '../../../hooks/useAppLayout'; -import useHistoryBack from '../../../hooks/useHistoryBack'; -import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; -import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver'; -import useLastCallback from '../../../hooks/useLastCallback'; -import useOldLang from '../../../hooks/useOldLang'; -import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; -import useOrderDiff from './hooks/useOrderDiff'; +import useAppLayout from '../../../../hooks/useAppLayout'; +import useHistoryBack from '../../../../hooks/useHistoryBack'; +import useInfiniteScroll from '../../../../hooks/useInfiniteScroll'; +import { useIntersectionObserver, useOnIntersect } from '../../../../hooks/useIntersectionObserver'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import usePreviousDeprecated from '../../../../hooks/usePreviousDeprecated'; +import useOrderDiff from '../hooks/useOrderDiff'; -import GroupCallTopPane from '../../calls/group/GroupCallTopPane'; -import GroupChatInfo from '../../common/GroupChatInfo'; -import Icon from '../../common/icons/Icon'; -import HeaderActions from '../../middle/HeaderActions'; -import Button from '../../ui/Button'; -import InfiniteScroll from '../../ui/InfiniteScroll'; -import Loading from '../../ui/Loading'; +import GroupCallTopPane from '../../../calls/group/GroupCallTopPane'; +import GroupChatInfo from '../../../common/GroupChatInfo'; +import Icon from '../../../common/icons/Icon'; +import PrivateChatInfo from '../../../common/PrivateChatInfo'; +import HeaderActions from '../../../middle/HeaderActions'; +import Button from '../../../ui/Button'; +import InfiniteScroll from '../../../ui/InfiniteScroll'; +import Loading from '../../../ui/Loading'; +import AllMessagesTopic from './AllMessagesTopic'; import EmptyForum from './EmptyForum'; import Topic from './Topic'; @@ -66,17 +68,17 @@ type StateProps = { const INTERSECTION_THROTTLE = 200; -const ForumPanel: FC = ({ +const ForumPanel = ({ chat, currentTopicId, isOpen, isHidden, topicsInfo, + withInterfaceAnimations, onTopicSearch, onCloseAnimationEnd, onOpenAnimationStart, - withInterfaceAnimations, -}) => { +}: OwnProps & StateProps) => { const { closeForumPanel, openChatWithInfo, loadTopics, } = getActions(); @@ -95,7 +97,7 @@ const ForumPanel: FC = ({ }, [topicsInfo, chatId]); const [isScrolled, setIsScrolled] = useState(false); - const lang = useOldLang(); + const lang = useLang(); const handleClose = useLastCallback(() => { closeForumPanel(); @@ -122,13 +124,17 @@ const ForumPanel: FC = ({ }); const orderedIds = useMemo(() => { - return topicsInfo + const ids = topicsInfo ? getOrderedTopics( Object.values(topicsInfo.topicsById), topicsInfo.orderedPinnedTopicIds, ).map(({ id }) => id) : []; - }, [topicsInfo]); + + if (!chat?.isBotForum) return ids; + + return [MAIN_THREAD_ID, ...ids]; + }, [chat?.isBotForum, topicsInfo]); const { orderDiffById, getAnimationType, onReorderAnimationEnd } = useOrderDiff(orderedIds, chat?.id); @@ -197,23 +203,37 @@ const ForumPanel: FC = ({ function renderTopics() { const viewportOffset = orderedIds.indexOf(viewportIds![0]); - return viewportIds?.map((id, i) => ( - - )); + return viewportIds?.map((id, i) => { + if (id === MAIN_THREAD_ID) { + return ( + + ); + } + + return ( + + ); + }); } const isLoading = topicsInfo === undefined; + if (!chat) return undefined; + return (
= ({ - {chat && ( + {isUserId(chat.id) ? ( + + ) : ( = ({ /> )} - {chat - && ( - - )} +
- {chat && } + {!isUserId(chat.id) && }
diff --git a/src/components/left/main/Topic.module.scss b/src/components/left/main/forum/Topic.module.scss similarity index 100% rename from src/components/left/main/Topic.module.scss rename to src/components/left/main/forum/Topic.module.scss diff --git a/src/components/left/main/Topic.tsx b/src/components/left/main/forum/Topic.tsx similarity index 81% rename from src/components/left/main/Topic.tsx rename to src/components/left/main/forum/Topic.tsx index 03199afc1..0d51030d5 100644 --- a/src/components/left/main/Topic.tsx +++ b/src/components/left/main/forum/Topic.tsx @@ -1,17 +1,17 @@ -import type { FC } from '../../../lib/teact/teact'; -import { memo } from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; +import type { FC } from '../../../../lib/teact/teact'; +import { memo } from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; import type { ApiChat, ApiDraft, ApiMessage, ApiMessageOutgoingStatus, ApiPeer, ApiTopic, ApiTypeStory, ApiTypingStatus, -} from '../../../api/types'; -import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import type { ChatAnimationTypes } from './hooks'; +} from '../../../../api/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; +import type { ChatAnimationTypes } from '../hooks'; -import { UNMUTE_TIMESTAMP } from '../../../config'; -import { groupStatefulContent } from '../../../global/helpers'; -import { getIsChatMuted } from '../../../global/helpers/notifications'; +import { UNMUTE_TIMESTAMP } from '../../../../config'; +import { groupStatefulContent } from '../../../../global/helpers'; +import { getIsChatMuted } from '../../../../global/helpers/notifications'; import { selectCanAnimateInterface, selectCanDeleteTopic, @@ -27,25 +27,25 @@ import { selectThreadInfo, selectThreadParam, selectTopics, -} from '../../../global/selectors'; -import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/browser/windowEnvironment'; -import buildClassName from '../../../util/buildClassName'; -import { createLocationHash } from '../../../util/routing'; -import renderText from '../../common/helpers/renderText'; +} from '../../../../global/selectors'; +import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment'; +import buildClassName from '../../../../util/buildClassName'; +import { createLocationHash } from '../../../../util/routing'; +import renderText from '../../../common/helpers/renderText'; -import useFlag from '../../../hooks/useFlag'; -import useLastCallback from '../../../hooks/useLastCallback'; -import useOldLang from '../../../hooks/useOldLang'; -import useChatListEntry from './hooks/useChatListEntry'; -import useTopicContextActions from './hooks/useTopicContextActions'; +import useFlag from '../../../../hooks/useFlag'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import useOldLang from '../../../../hooks/useOldLang'; +import useChatListEntry from '../hooks/useChatListEntry'; +import useTopicContextActions from '../hooks/useTopicContextActions'; -import Icon from '../../common/icons/Icon'; -import LastMessageMeta from '../../common/LastMessageMeta'; -import TopicIcon from '../../common/TopicIcon'; -import ConfirmDialog from '../../ui/ConfirmDialog'; -import ListItem from '../../ui/ListItem'; -import MuteChatModal from '../MuteChatModal.async'; -import ChatBadge from './ChatBadge'; +import Icon from '../../../common/icons/Icon'; +import LastMessageMeta from '../../../common/LastMessageMeta'; +import TopicIcon from '../../../common/TopicIcon'; +import ConfirmDialog from '../../../ui/ConfirmDialog'; +import ListItem from '../../../ui/ListItem'; +import MuteChatModal from '../../MuteChatModal.async'; +import ChatBadge from '../ChatBadge'; import styles from './Topic.module.scss'; @@ -165,7 +165,7 @@ const Topic: FC = ({ } openThread({ chatId, threadId: topic.id, shouldReplaceHistory: true }); - setViewForumAsMessages({ chatId, isEnabled: false }); + if (!chat.isBotForum && !chat.isMonoforum) setViewForumAsMessages({ chatId, isEnabled: false }); if (canScrollDown) { scrollMessageListToBottom(); @@ -263,7 +263,7 @@ export default memo(withGlobal( const typingStatus = selectThreadParam(global, chatId, topic.id, 'typingStatus'); const draft = selectDraft(global, chatId, topic.id); const threadInfo = selectThreadInfo(global, chatId, topic.id); - const wasTopicOpened = Boolean(threadInfo?.lastReadInboxMessageId); + const wasTopicOpened = chat?.isBotForum || Boolean(threadInfo?.lastReadInboxMessageId); const topics = selectTopics(global, chatId); const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; diff --git a/src/components/left/main/hooks/useTopicContextActions.ts b/src/components/left/main/hooks/useTopicContextActions.ts index 114741137..012631ee0 100644 --- a/src/components/left/main/hooks/useTopicContextActions.ts +++ b/src/components/left/main/hooks/useTopicContextActions.ts @@ -5,6 +5,7 @@ import type { ApiChat, ApiTopic } from '../../../../api/types'; import type { MenuItemContextAction } from '../../../ui/ListItem'; import { getCanManageTopic, getHasAdminRight } from '../../../../global/helpers'; +import { IS_TAURI } from '../../../../util/browser/globalEnvironment'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment'; import { compact } from '../../../../util/iteratees'; @@ -48,11 +49,11 @@ export default function useTopicContextActions({ openQuickPreview, } = getActions(); - const canToggleClosed = getCanManageTopic(chat, topic); + const canToggleClosed = getCanManageTopic(chat, topic) && !chat.isBotForum; const canTogglePinned = chat.isCreator || getHasAdminRight(chat, 'manageTopics'); const actionOpenInNewTab = IS_OPEN_IN_NEW_TAB_SUPPORTED && { - title: 'Open in new tab', + title: IS_TAURI ? lang('ChatListOpenInNewWindow') : lang('ChatListOpenInNewTab'), icon: 'open-in-new-tab', handler: () => { openChatInNewTab({ chatId: chat.id, threadId: topicId }); diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 0e13326f6..3b8d002fa 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -863,7 +863,8 @@ export default memo(withGlobal( const canGift = selectCanGift(global, chatId); const topic = selectTopic(global, chatId, threadId); - const canCreateTopic = chat.isForum && ( + // Disable manual creation for bot forums + const canCreateTopic = chat.isForum && !chat.isBotForum && ( chat.isCreator || !isUserRightBanned(chat, 'manageTopics') || getHasAdminRight(chat, 'manageTopics') ); const canEditTopic = topic && getCanManageTopic(chat, topic); diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index c28d090f5..e86ec9faa 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -1,6 +1,8 @@ .MessageList { --action-message-bg: var(--pattern-color); + scroll-snap-type: y proximity; + overflow-x: hidden; overflow-y: scroll; flex: 1; @@ -36,6 +38,10 @@ display: none; } + &.no-bottom-snap { + scroll-snap-type: none; + } + .messages-container { display: flex; flex-direction: column; @@ -51,6 +57,10 @@ margin-top: 100vh !important; } + .fab-trigger { + scroll-snap-align: end; + } + @media (max-width: 600px) { width: 100vw; // Patch for an issue on Android when rotating device diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 2486fb36a..86fa09b40 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -14,7 +14,7 @@ import { MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID, } from '../../config'; -import { forceMeasure, requestForcedReflow, requestMeasure } from '../../lib/fasterdom/fasterdom'; +import { forceMeasure, requestForcedReflow, requestMeasure, requestMutation } from '../../lib/fasterdom/fasterdom'; import { getIsSavedDialog, getMessageHtmlId, @@ -54,6 +54,7 @@ import { import { selectIsChatRestricted } from '../../global/selectors/chats'; import { selectActiveRestrictionReasons, selectCurrentMessageList } from '../../global/selectors/messages'; import animateScroll, { isAnimatingScroll, restartCurrentScrollAnimation } from '../../util/animateScroll'; +import { IS_FIREFOX } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; import { isUserId } from '../../util/entities/ids'; import { orderBy } from '../../util/iteratees'; @@ -145,6 +146,7 @@ type StateProps = { translationLanguage?: string; shouldAutoTranslate?: boolean; isActive?: boolean; + isBotForum?: boolean; shouldScrollToBottom?: boolean; }; @@ -166,13 +168,19 @@ const MESSAGE_REACTIONS_POLLING_INTERVAL = 20 * 1000; const MESSAGE_COMMENTS_POLLING_INTERVAL = 20 * 1000; const MESSAGE_FACT_CHECK_UPDATE_INTERVAL = 5 * 1000; const MESSAGE_STORY_POLLING_INTERVAL = 5 * 60 * 1000; + const BOTTOM_THRESHOLD = 50; +const BOTTOM_SNAP_THRESHOLD = 10; + const UNREAD_DIVIDER_TOP = 10; const SCROLL_DEBOUNCE = 200; const MESSAGE_ANIMATION_DURATION = 500; const BOTTOM_FOCUS_MARGIN = 0.5 * REM; const SELECT_MODE_ANIMATION_DURATION = 200; + const UNREAD_DIVIDER_CLASS = 'unread-divider'; +const FORCE_MESSAGES_SCROLL_CLASS = 'force-messages-scroll'; +const NO_BOTTOM_SNAP_CLASS = 'no-bottom-snap'; const runDebouncedForScroll = debounce((cb) => cb(), SCROLL_DEBOUNCE, false); @@ -188,6 +196,7 @@ const MessageList: FC = ({ canPost, isSynced, isActive, + isBotForum, shouldScrollToBottom, // eslint-disable-next-line @typescript-eslint/no-shadow isChatMonoforum, @@ -258,6 +267,7 @@ const MessageList: FC = ({ const memoFocusingIdRef = useRef(); const isScrollTopJustUpdatedRef = useRef(false); const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage)); + const scrollSnapDisabledTimerRef = useRef(); const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const hasOpenChatButton = isSavedDialog && threadId !== ANONYMOUS_USER_ID; @@ -498,6 +508,33 @@ const MessageList: FC = ({ const [getContainerHeight, prevContainerHeightRef] = useContainerHeight(containerRef, canPost && !isSelectModeActive); + const handleWheel = useLastCallback((e: React.WheelEvent) => { + // Firefox is finicky about bottom scroll snapping, so we enable it only when nearing the bottom + // https://bugzilla.mozilla.org/show_bug.cgi?id=1753188 + if (!IS_FIREFOX) return; + + const container = containerRef.current; + if (!container) return; + + const scrollTop = container.scrollTop; + const scrollHeight = container.scrollHeight; + const offsetHeight = container.offsetHeight; + const isNearBottomForSnap = scrollTop >= scrollHeight - offsetHeight - BOTTOM_SNAP_THRESHOLD; + if (!isNearBottomForSnap) return; + + if (e.deltaY < 0) { + clearTimeout(scrollSnapDisabledTimerRef.current); + requestMutation(() => { + addExtraClass(container, NO_BOTTOM_SNAP_CLASS); + container.scrollBy(0, -BOTTOM_SNAP_THRESHOLD); // Manually scroll to prevent ignoring first event + }); + } else { + requestMutation(() => { + removeExtraClass(container, NO_BOTTOM_SNAP_CLASS); + }); + } + }); + // Initial message loading useEffect(() => { if (!loadMoreAround || !isChatLoaded || isRestricted || focusingId) { @@ -591,21 +628,30 @@ const MessageList: FC = ({ isViewportNewest && wasMessageAdded && (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2) - && !container.parentElement!.classList.contains('force-messages-scroll') + && !container.parentElement!.classList.contains(FORCE_MESSAGES_SCROLL_CLASS) && forceMeasure(() => ( (container.firstElementChild as HTMLDivElement).clientHeight <= container.offsetHeight * 2 )) ) { - addExtraClass(container.parentElement!, 'force-messages-scroll'); - container.parentElement!.classList.add('force-messages-scroll'); + addExtraClass(container.parentElement!, FORCE_MESSAGES_SCROLL_CLASS); setTimeout(() => { if (container.parentElement) { - removeExtraClass(container.parentElement, 'force-messages-scroll'); + removeExtraClass(container.parentElement, FORCE_MESSAGES_SCROLL_CLASS); } }, MESSAGE_ANIMATION_DURATION); } + if (wasMessageAdded) { + clearTimeout(scrollSnapDisabledTimerRef.current); + + addExtraClass(container, NO_BOTTOM_SNAP_CLASS); + + scrollSnapDisabledTimerRef.current = window.setTimeout(() => { + removeExtraClass(container, NO_BOTTOM_SNAP_CLASS); + }, MESSAGE_ANIMATION_DURATION); + } + requestForcedReflow(() => { const { scrollTop, scrollHeight, offsetHeight } = container; const scrollOffset = scrollOffsetRef.current; @@ -722,6 +768,7 @@ const MessageList: FC = ({ !isReady && 'is-animating', hasOpenChatButton && 'saved-dialog', isChatProtected && 'hide-on-print', + IS_FIREFOX && NO_BOTTOM_SNAP_CLASS, ); const hasMessages = Boolean((messageIds && messageGroups) || lastMessage); @@ -804,6 +851,7 @@ const MessageList: FC = ({ noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current} isQuickPreview={isQuickPreview} canPost={canPost} + isBotForum={isBotForum} shouldScrollToBottom={shouldScrollToBottom} onScrollDownToggle={onScrollDownToggle} onNotchToggle={onNotchToggle} @@ -822,6 +870,7 @@ const MessageList: FC = ({ activeKey={activeKey} shouldCleanup onScroll={handleScroll} + onWheel={handleWheel} onMouseDown={preventMessageInputBlur} > {renderContent()} @@ -937,6 +986,7 @@ export default memo(withGlobal( canTranslate, translationLanguage, shouldAutoTranslate, + isBotForum: chat.isBotForum, shouldScrollToBottom, }; }, diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 9db6e3ada..3e4383a7c 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -37,6 +37,7 @@ import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; import useMessageObservers from './hooks/useMessageObservers'; import useScrollHooks from './hooks/useScrollHooks'; +import Icon from '../common/icons/Icon'; import MiniTable, { type TableEntry } from '../common/MiniTable'; import ActionMessage from './message/ActionMessage'; import Message from './message/Message'; @@ -59,6 +60,7 @@ interface OwnProps { withUsers: boolean; isChannelChat: boolean | undefined; isChatMonoforum?: boolean; + isBotForum?: boolean; isEmptyThread?: boolean; isComments?: boolean; noAvatars: boolean; @@ -99,6 +101,7 @@ const MessageListContent = ({ withUsers, isChannelChat, isChatMonoforum, + isBotForum, noAvatars, containerRef, anchorIdRef, @@ -243,6 +246,20 @@ const MessageListContent = ({ return undefined; }; + const renderBotForumTopicAction = () => { + if (!isBotForum || threadId !== MAIN_THREAD_ID) return undefined; + return ( +
+
+ +

{lang('BotForumActionNew')}

+ {lang('BotForumActionNewDescription')} + +
+
+ ); + }; + const messageCountToAnimate = noAppearanceAnimation ? 0 : messageGroups.reduce((acc, messageGroup) => { return acc + messageGroup.senderGroups.flat().length; }, 0); @@ -448,6 +465,7 @@ const MessageListContent = ({ {shouldRenderAccountInfo && } {dateGroups.flat()} + {isViewportNewest && renderBotForumTopicAction()} {withHistoryTriggers && (
= ({ = ({ }) => { const { clickBotInlineButton } = getActions(); - const lang = useOldLang(); + const lang = useLang(); const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose); const { isKeyboardSingleUse } = message || {}; diff --git a/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx b/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx index f950f454a..6656137cc 100644 --- a/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx +++ b/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx @@ -7,11 +7,9 @@ import { STARS_ICON_PLACEHOLDER } from '../../../../config'; import { replaceWithTeact } from '../../../../util/replaceWithTeact'; import renderText from '../../../common/helpers/renderText'; -import { type OldLangFn } from '../../../../hooks/useOldLang'; - import Icon from '../../../common/icons/Icon'; -export default function renderKeyboardButtonText(lang: OldLangFn | LangFn, button: ApiKeyboardButton): TeactNode { +export default function renderKeyboardButtonText(lang: LangFn, button: ApiKeyboardButton): TeactNode { if (button.type === 'receipt') { return lang('PaymentReceipt'); } diff --git a/src/components/middle/hooks/useMessageObservers.ts b/src/components/middle/hooks/useMessageObservers.ts index 47c368e90..ea6330b4c 100644 --- a/src/components/middle/hooks/useMessageObservers.ts +++ b/src/components/middle/hooks/useMessageObservers.ts @@ -83,7 +83,7 @@ export default function useMessageObservers( }); if (!isQuickPreview) { - if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) { + if (memoFirstUnreadIdRef.current && maxId && maxId >= memoFirstUnreadIdRef.current) { markMessageListRead({ maxId }); } diff --git a/src/components/middle/message/ActionMessage.module.scss b/src/components/middle/message/ActionMessage.module.scss index d7391ae69..117b1cc25 100644 --- a/src/components/middle/message/ActionMessage.module.scss +++ b/src/components/middle/message/ActionMessage.module.scss @@ -283,3 +283,23 @@ font-size: 1rem; vertical-align: middle; } + +.botForumTopicIcon { + padding: 1.25rem; + border-radius: 50%; + font-size: 2.5rem; + background-color: var(--action-message-bg); +} + +.botForumTopicTitle { + margin-block: 0.5rem 0; +} + +.botForumTopicDescription { + font-weight: var(--font-weight-normal); +} + +.botForumTopicArrow { + font-size: 1.5rem; + opacity: 0.5; +} diff --git a/src/components/middle/message/ActionMessage.tsx b/src/components/middle/message/ActionMessage.tsx index 32366f584..1a6997ee3 100644 --- a/src/components/middle/message/ActionMessage.tsx +++ b/src/components/middle/message/ActionMessage.tsx @@ -40,7 +40,6 @@ import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useFlag from '../../../hooks/useFlag'; import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../hooks/useLastCallback'; -import useMessageResizeObserver from '../../../hooks/useResizeMessageObserver'; import useShowTransition from '../../../hooks/useShowTransition'; import { type OnIntersectPinnedMessage } from '../hooks/usePinnedMessage'; import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter'; @@ -125,11 +124,11 @@ const ActionMessage = ({ hasUnreadReaction, isResizingContainer, scrollTargetPosition, + isAccountFrozen, onIntersectPinnedMessage, observeIntersectionForBottom, observeIntersectionForLoading, observeIntersectionForPlaying, - isAccountFrozen, }: OwnProps & StateProps) => { const { requestConfetti, @@ -168,8 +167,6 @@ const ActionMessage = ({ useOnIntersect(ref, !shouldSkipRender ? observeIntersectionForBottom : undefined); - useMessageResizeObserver(ref, !shouldSkipRender && isLastInList && action.type !== 'channelJoined'); - useEnsureMessage( replyToPeerId || chatId, replyToMsgId, diff --git a/src/components/middle/message/ActionMessageText.tsx b/src/components/middle/message/ActionMessageText.tsx index f8770963a..df6e86d32 100644 --- a/src/components/middle/message/ActionMessageText.tsx +++ b/src/components/middle/message/ActionMessageText.tsx @@ -40,6 +40,7 @@ import { getPinnedMediaValue, renderMessageLink, renderPeerLink, + renderTopicLink, translateWithYou, } from './helpers/messageActions'; @@ -81,7 +82,6 @@ const ActionMessageText = ({ asPreview, }: OwnProps & StateProps) => { const { - openThread, openTelegramLink, openUrl, } = getActions(); @@ -231,18 +231,15 @@ const ActionMessageText = ({ const topicId = selectThreadIdFromMessage(global, message); - const topicLink = ( - openThread({ chatId, threadId: topicId })} - > + const topicLinkContent = ( + <> {iconEmojiId ? : } {NBSP} {renderText(title)} - + ); + const topicLink = renderTopicLink(chatId, Number(topicId), topicLinkContent, asPreview); return lang('ActionTopicCreated', { topic: topicLink }, { withNodes: true }); } @@ -253,12 +250,8 @@ const ActionMessageText = ({ const topicId = selectThreadIdFromMessage(global, message); const currentTopic = selectTopic(global, chatId, topicId); - const topicLink = ( - openThread({ chatId, threadId: topicId })} - > + const topicLinkContent = ( + <> {iconEmojiId && iconEmojiId !== DEFAULT_TOPIC_ICON_ID ? : ( @@ -270,17 +263,12 @@ const ActionMessageText = ({ )} {topicId !== GENERAL_TOPIC_ID && NBSP} {renderText(title || currentTopic?.title || lang('ActionTopicPlaceholder'))} - + ); + const topicLink = renderTopicLink(chatId, Number(topicId), topicLinkContent, asPreview); - const topicPlaceholderLink = ( - openThread({ chatId, threadId: topicId })} - > - {lang('ActionTopicPlaceholder')} - + const topicPlaceholderLink = renderTopicLink( + chatId, Number(topicId), lang('ActionTopicPlaceholder'), asPreview, ); if (isClosed !== undefined) { diff --git a/src/components/middle/message/InlineButtons.tsx b/src/components/middle/message/InlineButtons.tsx index a9949165f..9a739a82a 100644 --- a/src/components/middle/message/InlineButtons.tsx +++ b/src/components/middle/message/InlineButtons.tsx @@ -1,13 +1,12 @@ -import type { FC, TeactNode } from '../../../lib/teact/teact'; +import type { TeactNode } from '../../../lib/teact/teact'; import { memo, useMemo } from '../../../lib/teact/teact'; -import type { ApiKeyboardButton, ApiMessage } from '../../../api/types'; -import type { ActionPayloads } from '../../../global/types'; +import type { ApiKeyboardButton } from '../../../api/types'; import { RE_TME_LINK, TME_LINK_PREFIX } from '../../../config'; import renderKeyboardButtonText from '../composer/helpers/renderKeyboardButtonText'; -import useOldLang from '../../../hooks/useOldLang'; +import useLang from '../../../hooks/useLang'; import Icon from '../../common/icons/Icon'; import Button from '../../ui/Button'; @@ -15,12 +14,12 @@ import Button from '../../ui/Button'; import './InlineButtons.scss'; type OwnProps = { - message: ApiMessage; - onClick: (payload: ActionPayloads['clickBotInlineButton']) => void; + inlineButtons: ApiKeyboardButton[][]; + onClick: (payload: ApiKeyboardButton) => void; }; -const InlineButtons: FC = ({ message, onClick }) => { - const lang = useOldLang(); +const InlineButtons = ({ inlineButtons, onClick }: OwnProps) => { + const lang = useLang(); const renderIcon = (button: ApiKeyboardButton) => { const { type } = button; @@ -66,15 +65,15 @@ const InlineButtons: FC = ({ message, onClick }) => { const buttonTexts = useMemo(() => { const texts: TeactNode[][] = []; - message.inlineButtons!.forEach((row) => { + inlineButtons.forEach((row) => { texts.push(row.map((button) => renderKeyboardButtonText(lang, button))); }); return texts; - }, [lang, message.inlineButtons]); + }, [lang, inlineButtons]); return (
- {message.inlineButtons!.map((row, i) => ( + {inlineButtons.map((row, i) => (
{row.map((button, j) => (