From 1fef97b82e7db3c7176084b5f079b43e3379ad46 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 6 Jan 2023 01:15:52 +0100 Subject: [PATCH] Introduce Forums (#2174) --- playwright.config.ts | 1 + src/api/gramjs/apiBuilders/appConfig.ts | 5 +- src/api/gramjs/apiBuilders/chats.ts | 52 +- src/api/gramjs/apiBuilders/messages.ts | 62 +- src/api/gramjs/apiBuilders/misc.ts | 18 + src/api/gramjs/apiBuilders/statistics.ts | 1 + src/api/gramjs/methods/bots.ts | 10 +- src/api/gramjs/methods/chats.ts | 213 +++++- src/api/gramjs/methods/client.ts | 2 + src/api/gramjs/methods/index.ts | 5 +- src/api/gramjs/methods/messages.ts | 57 +- src/api/gramjs/updater.ts | 65 +- src/api/types/chats.ts | 32 +- src/api/types/messages.ts | 3 +- src/api/types/misc.ts | 2 + src/api/types/updates.ts | 36 +- src/assets/fonts/icomoon.woff | Bin 49808 -> 50980 bytes src/assets/fonts/icomoon.woff2 | Bin 23336 -> 23744 bytes src/assets/icons/forumTopic/blue.svg | 1 + src/assets/icons/forumTopic/green.svg | 1 + src/assets/icons/forumTopic/grey.svg | 1 + src/assets/icons/forumTopic/red.svg | 1 + src/assets/icons/forumTopic/rose.svg | 1 + src/assets/icons/forumTopic/violet.svg | 1 + src/assets/icons/forumTopic/yellow.svg | 1 + .../calls/group/GroupCallTopPane.tsx | 3 + .../common/AnimatedCounter.module.scss | 4 + src/components/common/AnimatedCounter.tsx | 5 +- src/components/common/Avatar.scss | 17 +- src/components/common/Avatar.tsx | 2 + src/components/common/ChatExtra.tsx | 59 +- .../common/ChatForumLastMessage.module.scss | 158 +++++ .../common/ChatForumLastMessage.tsx | 147 +++++ src/components/common/ChatLink.tsx | 7 +- src/components/common/ChatOrUserPicker.scss | 39 ++ src/components/common/ChatOrUserPicker.tsx | 266 ++++++-- src/components/common/CustomEmoji.module.scss | 5 + src/components/common/EmbeddedMessage.tsx | 4 +- src/components/common/GroupChatInfo.tsx | 75 ++- src/components/common/PickerSelectedItem.scss | 12 +- src/components/common/PickerSelectedItem.tsx | 1 + src/components/common/ProfileInfo.module.scss | 179 ++++++ src/components/common/ProfileInfo.scss | 184 +----- src/components/common/ProfileInfo.tsx | 78 ++- src/components/common/ProfilePhoto.scss | 2 +- src/components/common/RecipientPicker.tsx | 3 +- src/components/common/TopicChip.module.scss | 33 + src/components/common/TopicChip.tsx | 48 ++ .../common/TopicDefaultIcon.module.scss | 30 + src/components/common/TopicDefaultIcon.tsx | 43 ++ src/components/common/TopicIcon.tsx | 52 ++ src/components/common/UiLoader.module.scss | 2 +- .../helpers/renderActionMessageText.tsx | 14 +- src/components/left/ArchivedChats.scss | 23 + src/components/left/ArchivedChats.tsx | 47 +- src/components/left/LeftColumn.tsx | 43 +- src/components/left/main/Badge.scss | 12 +- src/components/left/main/Badge.tsx | 73 ++- src/components/left/main/Chat.scss | 125 +++- src/components/left/main/Chat.tsx | 305 ++++----- src/components/left/main/ChatFolders.tsx | 9 +- src/components/left/main/ChatList.tsx | 58 +- ...ptyFolder.scss => EmptyFolder.module.scss} | 18 +- src/components/left/main/EmptyFolder.tsx | 16 +- src/components/left/main/EmptyTopic.tsx | 58 ++ .../left/main/ForumPanel.module.scss | 96 +++ src/components/left/main/ForumPanel.tsx | 256 ++++++++ src/components/left/main/LeftMain.scss | 8 + src/components/left/main/LeftMain.tsx | 29 +- src/components/left/main/LeftMainHeader.scss | 59 +- src/components/left/main/LeftMainHeader.tsx | 28 +- src/components/left/main/Topic.module.scss | 34 + src/components/left/main/Topic.tsx | 236 +++++++ .../left/main/hooks/useChatAnimationType.ts | 6 +- .../left/main/hooks/useChatListEntry.tsx | 224 +++++++ .../left/main/hooks/useChatOrderDiff.ts | 36 ++ .../useLeftHeaderButtonRtlForumTransition.ts | 20 + .../left/main/hooks/useTopicContextActions.ts | 98 +++ .../left/search/ChatMessageResults.tsx | 56 +- src/components/left/search/LeftSearch.scss | 36 +- .../left/search/LeftSearchResultTopic.tsx | 71 ++ .../left/search/RecentContacts.scss | 6 +- src/components/main/DraftRecipientPicker.tsx | 9 +- .../main/ForwardRecipientPicker.tsx | 8 +- src/components/main/Main.scss | 14 +- src/components/main/WebAppModal.tsx | 4 +- src/components/mediaViewer/MediaViewer.tsx | 4 +- src/components/middle/ActionMessage.tsx | 22 +- src/components/middle/HeaderActions.tsx | 32 +- src/components/middle/HeaderMenuContainer.tsx | 87 ++- src/components/middle/HeaderPinnedMessage.tsx | 3 +- src/components/middle/MessageList.tsx | 9 +- src/components/middle/MessageListContent.tsx | 6 +- .../middle/MessageListHistoryHandler.tsx | 4 +- src/components/middle/MiddleColumn.tsx | 41 +- src/components/middle/MiddleHeader.scss | 32 +- src/components/middle/MiddleHeader.tsx | 81 ++- .../middle/composer/AttachBotItem.tsx | 3 + src/components/middle/composer/AttachMenu.tsx | 3 + src/components/middle/composer/Composer.tsx | 22 +- .../calculateMiddleFooterTransforms.ts | 2 +- .../middle/message/CommentButton.scss | 2 +- .../middle/message/ContextMenuContainer.tsx | 11 +- src/components/middle/message/Location.tsx | 4 +- src/components/middle/message/Message.scss | 6 +- src/components/middle/message/Message.tsx | 61 +- .../middle/message/MessageMeta.scss | 2 +- .../middle/message/_message-content.scss | 56 +- .../message/helpers/buildContentClassName.ts | 10 +- .../middle/message/hooks/useInnerHandlers.ts | 15 +- .../middle/message/hooks/useOuterHandlers.ts | 4 +- src/components/right/Profile.scss | 3 - src/components/right/Profile.tsx | 70 +- src/components/right/RightColumn.tsx | 9 + src/components/right/RightHeader.tsx | 43 +- src/components/right/hooks/useProfileState.ts | 9 +- .../right/hooks/useProfileViewportIds.ts | 18 +- .../right/management/ManageChannel.tsx | 2 +- .../right/management/ManageGroup.tsx | 16 +- .../management/ManageGroupAdminRights.tsx | 16 + .../management/ManageGroupPermissions.tsx | 15 + .../management/ManageGroupUserPermissions.tsx | 14 + .../right/management/Management.scss | 4 + src/components/ui/AvatarEditable.scss | 5 + src/components/ui/AvatarEditable.tsx | 3 + src/components/ui/Button.tsx | 4 + src/components/ui/DropdownMenu.tsx | 3 + src/components/ui/InfiniteScroll.tsx | 3 + src/components/ui/ListItem.scss | 4 + src/components/ui/ListItem.tsx | 17 +- src/components/ui/Menu.tsx | 12 +- src/components/ui/MenuItem.scss | 26 + src/components/ui/Tab.tsx | 4 + src/config.ts | 7 + src/global/actions/api/bots.ts | 26 +- src/global/actions/api/chats.ts | 230 ++++++- src/global/actions/api/globalSearch.ts | 39 +- src/global/actions/api/initial.ts | 1 + src/global/actions/api/localSearch.ts | 14 +- src/global/actions/api/messages.ts | 110 ++-- src/global/actions/api/sync.ts | 94 +-- src/global/actions/apiUpdaters/chats.ts | 83 ++- src/global/actions/apiUpdaters/messages.ts | 97 ++- src/global/actions/apiUpdaters/settings.ts | 15 +- src/global/actions/ui/chats.ts | 6 +- src/global/actions/ui/globalSearch.ts | 6 + src/global/actions/ui/localSearch.ts | 6 +- src/global/actions/ui/messages.ts | 28 +- src/global/cache.ts | 25 +- src/global/helpers/chats.ts | 79 ++- src/global/initialState.ts | 2 +- src/global/reducers/chats.ts | 90 ++- src/global/reducers/localSearch.ts | 40 +- src/global/reducers/messages.ts | 65 +- src/global/selectors/localSearch.ts | 8 +- src/global/selectors/messages.ts | 157 +++-- src/global/selectors/ui.ts | 6 + src/global/types.ts | 89 ++- src/hooks/useContextMenuPosition.ts | 23 +- src/hooks/useFlag.ts | 2 +- src/hooks/useForumPanelRender.ts | 25 + src/hooks/useInputFocusOnOpen.ts | 4 +- src/hooks/useResize.ts | 27 +- src/lib/gramjs/client/MockClient.ts | 605 ++++++++++-------- src/lib/gramjs/client/MockSender.ts | 14 + src/lib/gramjs/client/__data__/Cumshot.tgs | Bin 0 -> 64906 bytes src/lib/gramjs/client/__data__/Eggplant.tgs | Bin 0 -> 17805 bytes src/lib/gramjs/client/__data__/Peach.tgs | Bin 0 -> 30301 bytes src/lib/gramjs/client/__data__/lock.png | Bin 0 -> 5631 bytes .../__invokeMiddlewares__/forums/no-topics.ts | 9 + src/lib/gramjs/client/__mocks__/default.json | 118 ++++ .../__mocks__/forums/can-delete-messages.json | 94 +++ .../__mocks__/forums/can-manage-topics.json | 94 +++ .../client/__mocks__/forums/default.json | 93 +++ .../client/__mocks__/forums/no-topics.json | 74 +++ .../forums/owner-of-topic-messages-lots.json | 159 +++++ .../owner-of-topic-not-outgoing-messages.json | 101 +++ .../__mocks__/forums/owner-of-topic.json | 96 +++ .../gramjs/client/__mocks__/forums/owner.json | 99 +++ .../client/__mocks__/forums/pinned-a-lot.json | 116 ++++ .../client/__mocks__/forums/topics-a-lot.json | 199 ++++++ src/lib/gramjs/client/__mocks__/history.json | 104 +++ src/lib/gramjs/client/auth.ts | 1 + src/lib/gramjs/client/mockUtils/MockTypes.ts | 93 +++ .../createMockedAvailableReaction.ts | 27 + .../client/mockUtils/createMockedChannel.ts | 31 + .../client/mockUtils/createMockedChat.ts | 27 + .../mockUtils/createMockedChatAdminRights.ts | 16 + .../mockUtils/createMockedChatBannedRights.ts | 17 + .../client/mockUtils/createMockedDialog.ts | 18 + .../mockUtils/createMockedDialogFilter.ts | 24 + .../client/mockUtils/createMockedDocument.ts | 30 + .../mockUtils/createMockedForumTopic.ts | 41 ++ .../client/mockUtils/createMockedJSON.ts | 38 ++ .../client/mockUtils/createMockedMessage.ts | 36 ++ .../mockUtils/createMockedMessageMedia.ts | 19 + .../mockUtils/createMockedMessageReactions.ts | 25 + .../client/mockUtils/createMockedPhoto.ts | 44 ++ .../client/mockUtils/createMockedReplies.ts | 23 + .../client/mockUtils/createMockedReplyTo.ts | 22 + .../mockUtils/createMockedTypeInputPeer.ts | 30 + .../client/mockUtils/createMockedTypePeer.ts | 28 + .../client/mockUtils/createMockedUser.ts | 24 + .../mockUtils/getDocumentIdFromLocation.ts | 13 + .../client/mockUtils/getIdFromInputPeer.ts | 17 + src/lib/gramjs/tl/AllTLObjects.js | 2 +- src/lib/gramjs/tl/api.d.ts | 85 ++- src/lib/gramjs/tl/apiTl.js | 26 +- src/lib/gramjs/tl/static/api.json | 9 +- src/lib/gramjs/tl/static/api.tl | 31 +- src/lib/teact/teact-dom.ts | 2 + src/styles/Telegram T.json | 462 +++++++------ src/styles/_common.scss | 2 +- src/styles/_variables.scss | 3 + src/styles/icons.scss | 12 + src/styles/themes.json | 10 +- src/util/deeplink.ts | 4 +- src/util/folderManager.ts | 13 +- src/util/forumColors.ts | 27 + src/util/notifications.ts | 5 + src/util/routing.ts | 41 +- 221 files changed, 8144 insertions(+), 1599 deletions(-) create mode 100644 src/assets/icons/forumTopic/blue.svg create mode 100644 src/assets/icons/forumTopic/green.svg create mode 100644 src/assets/icons/forumTopic/grey.svg create mode 100644 src/assets/icons/forumTopic/red.svg create mode 100644 src/assets/icons/forumTopic/rose.svg create mode 100644 src/assets/icons/forumTopic/violet.svg create mode 100644 src/assets/icons/forumTopic/yellow.svg create mode 100644 src/components/common/ChatForumLastMessage.module.scss create mode 100644 src/components/common/ChatForumLastMessage.tsx create mode 100644 src/components/common/ProfileInfo.module.scss create mode 100644 src/components/common/TopicChip.module.scss create mode 100644 src/components/common/TopicChip.tsx create mode 100644 src/components/common/TopicDefaultIcon.module.scss create mode 100644 src/components/common/TopicDefaultIcon.tsx create mode 100644 src/components/common/TopicIcon.tsx rename src/components/left/main/{EmptyFolder.scss => EmptyFolder.module.scss} (70%) create mode 100644 src/components/left/main/EmptyTopic.tsx create mode 100644 src/components/left/main/ForumPanel.module.scss create mode 100644 src/components/left/main/ForumPanel.tsx create mode 100644 src/components/left/main/Topic.module.scss create mode 100644 src/components/left/main/Topic.tsx create mode 100644 src/components/left/main/hooks/useChatListEntry.tsx create mode 100644 src/components/left/main/hooks/useChatOrderDiff.ts create mode 100644 src/components/left/main/hooks/useLeftHeaderButtonRtlForumTransition.ts create mode 100644 src/components/left/main/hooks/useTopicContextActions.ts create mode 100644 src/components/left/search/LeftSearchResultTopic.tsx create mode 100644 src/hooks/useForumPanelRender.ts create mode 100644 src/lib/gramjs/client/MockSender.ts create mode 100644 src/lib/gramjs/client/__data__/Cumshot.tgs create mode 100644 src/lib/gramjs/client/__data__/Eggplant.tgs create mode 100644 src/lib/gramjs/client/__data__/Peach.tgs create mode 100644 src/lib/gramjs/client/__data__/lock.png create mode 100644 src/lib/gramjs/client/__invokeMiddlewares__/forums/no-topics.ts create mode 100644 src/lib/gramjs/client/__mocks__/default.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/can-delete-messages.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/can-manage-topics.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/default.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/no-topics.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/owner-of-topic-messages-lots.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/owner-of-topic-not-outgoing-messages.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/owner-of-topic.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/owner.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/pinned-a-lot.json create mode 100644 src/lib/gramjs/client/__mocks__/forums/topics-a-lot.json create mode 100644 src/lib/gramjs/client/__mocks__/history.json create mode 100644 src/lib/gramjs/client/mockUtils/MockTypes.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedAvailableReaction.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedChannel.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedChat.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedChatAdminRights.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedChatBannedRights.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedDialog.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedDialogFilter.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedDocument.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedForumTopic.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedJSON.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedMessage.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedMessageMedia.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedMessageReactions.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedPhoto.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedReplies.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedReplyTo.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedTypeInputPeer.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedTypePeer.ts create mode 100644 src/lib/gramjs/client/mockUtils/createMockedUser.ts create mode 100644 src/lib/gramjs/client/mockUtils/getDocumentIdFromLocation.ts create mode 100644 src/lib/gramjs/client/mockUtils/getIdFromInputPeer.ts create mode 100644 src/util/forumColors.ts diff --git a/playwright.config.ts b/playwright.config.ts index 6d9b52b82..d5c6a5908 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,7 @@ const config: PlaywrightTestConfig = { video: 'retain-on-failure', trace: 'on-first-retry', }, + reporter: [['html', { outputFolder: 'playwright-report' }]], projects: [ { name: 'chromium', diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 57e26b67f..109e76761 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -14,7 +14,7 @@ type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit type LimitKey = `${Limit}_${LimitType}`; type LimitsConfig = Record; -interface GramJsAppConfig extends LimitsConfig { +export interface GramJsAppConfig extends LimitsConfig { emojies_sounds: Record 0 : undefined), + }; +} diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 62c49bfa7..48a5f85d7 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -152,7 +152,10 @@ type UniversalMessage = ( )> ); -export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalMessage): ApiMessage { +export function buildApiMessageWithChatId( + chatId: string, + mtpMessage: UniversalMessage, +): ApiMessage { const fromId = mtpMessage.fromId ? getApiChatIdFromMtpPeer(mtpMessage.fromId) : undefined; const peerId = mtpMessage.peerId ? getApiChatIdFromMtpPeer(mtpMessage.peerId) : undefined; const isChatWithSelf = !fromId && chatId === currentUserId; @@ -167,7 +170,9 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM const isInvoiceMedia = mtpMessage.media instanceof GramJs.MessageMediaInvoice && Boolean(mtpMessage.media.extendedMedia); - const { replyToMsgId, replyToTopId, replyToPeerId } = mtpMessage.replyTo || {}; + const { + replyToMsgId, replyToTopId, forumTopic, replyToPeerId, + } = mtpMessage.replyTo || {}; const isEdited = mtpMessage.editDate && !mtpMessage.editHide; const { inlineButtons, keyboardButtons, keyboardPlaceholder, isKeyboardSingleUse, @@ -195,6 +200,7 @@ export function buildApiMessageWithChatId(chatId: string, mtpMessage: UniversalM reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), ...(emojiOnlyCount && { emojiOnlyCount }), ...(replyToMsgId && { replyToMessageId: replyToMsgId }), + ...(forumTopic && { isTopicReply: true }), ...(replyToPeerId && { replyToChatId: getApiChatIdFromMtpPeer(replyToPeerId) }), ...(replyToTopId && { replyToTopMessageId: replyToTopId }), ...(forwardInfo && { forwardInfo }), @@ -1017,6 +1023,23 @@ function buildAction( currency = action.currency; amount = action.amount.toJSNumber(); months = action.months; + } else if (action instanceof GramJs.MessageActionTopicCreate) { + text = 'TopicWasCreatedAction'; + type = 'topicCreate'; + translationValues.push(action.title); + } else if (action instanceof GramJs.MessageActionTopicEdit) { + if (action.closed !== undefined) { + text = action.closed ? 'TopicWasClosedAction' : 'TopicWasReopenedAction'; + translationValues.push('%action_origin%', '%action_topic%'); + } else if (action.hidden !== undefined) { + text = action.hidden ? 'TopicHidden2' : 'TopicWasUnhiddenAction'; + } else if (action.title) { + text = 'TopicRenamedTo'; + translationValues.push('%action_origin%', action.title); + } else { + // TODO[forums] Support icon changed action + text = 'ChatList.UnsupportedMessage'; + } } else { text = 'ChatList.UnsupportedMessage'; } @@ -1238,6 +1261,7 @@ export function buildLocalMessage( const localId = getNextLocalMessageId(); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; + const isForum = chat.isForum; const message = { id: localId, @@ -1260,13 +1284,14 @@ export function buildLocalMessage( senderId: sendAs?.id || currentUserId, ...(replyingTo && { replyToMessageId: replyingTo }), ...(replyingToTopId && { replyToTopMessageId: replyingToTopId }), + ...((replyingTo || replyingToTopId) && isForum && { isTopicReply: true }), ...(groupedId && { groupedId, ...(media && (media.photo || media.video) && { isInAlbum: true }), }), ...(scheduledAt && { isScheduled: true }), isForwardingAllowed: true, - }; + } satisfies ApiMessage; const emojiOnlyCount = getEmojiOnlyCountForMessage(message.content, message.groupedId); @@ -1276,15 +1301,25 @@ export function buildLocalMessage( }; } -export function buildLocalForwardedMessage( - toChat: ApiChat, - message: ApiMessage, - serverTimeOffset: number, - scheduledAt?: number, - noAuthors?: boolean, - noCaptions?: boolean, - isCurrentUserPremium?: boolean, -): ApiMessage { +export function buildLocalForwardedMessage({ + toChat, + toThreadId, + message, + serverTimeOffset, + scheduledAt, + noAuthors, + noCaptions, + isCurrentUserPremium, +}: { + toChat: ApiChat; + toThreadId?: number; + message: ApiMessage; + serverTimeOffset: number; + scheduledAt?: number; + noAuthors?: boolean; + noCaptions?: boolean; + isCurrentUserPremium?: boolean; +}): ApiMessage { const localId = getNextLocalMessageId(); const { content, @@ -1322,6 +1357,8 @@ export function buildLocalForwardedMessage( sendingState: 'messageSendingStatePending', groupedId, isInAlbum, + isForwardingAllowed: true, + replyToTopMessageId: toThreadId, ...(emojiOnlyCount && { emojiOnlyCount }), // Forward info doesn't get added when users forwards his own messages, also when forwarding audio ...(message.chatId !== currentUserId && !isAudio && !noAuthors && { @@ -1335,7 +1372,6 @@ export function buildLocalForwardedMessage( }), ...(message.chatId === currentUserId && !noAuthors && { forwardInfo: message.forwardInfo }), ...(scheduledAt && { isScheduled: true }), - isForwardingAllowed: true, }; } diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 02f125cc7..6dc3179d3 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -139,6 +139,24 @@ export function buildApiNotifyException( }; } +export function buildApiNotifyExceptionTopic( + notifySettings: GramJs.TypePeerNotifySettings, peer: GramJs.TypePeer, topicId: number, serverTimeOffset: number, +) { + const { + silent, muteUntil, showPreviews, otherSound, + } = notifySettings; + + const hasSound = Boolean(otherSound && !(otherSound instanceof GramJs.NotificationSoundNone)); + + return { + chatId: getApiChatIdFromMtpPeer(peer), + topicId, + isMuted: silent || (typeof muteUntil === 'number' && getServerTime(serverTimeOffset) < muteUntil), + ...(!hasSound && { isSilent: true }), + ...(showPreviews !== undefined && { shouldShowPreviews: Boolean(showPreviews) }), + }; +} + function buildApiCountry(country: GramJs.help.Country, code: GramJs.help.CountryCode) { const { hidden, iso2, defaultName, name, diff --git a/src/api/gramjs/apiBuilders/statistics.ts b/src/api/gramjs/apiBuilders/statistics.ts index 5bec4c917..9e6b92314 100644 --- a/src/api/gramjs/apiBuilders/statistics.ts +++ b/src/api/gramjs/apiBuilders/statistics.ts @@ -82,6 +82,7 @@ export function buildMessagePublicForwards( chat: { id: peerId, type: 'chatTypeChannel', + title: (channel as GramJs.Channel).title, username: (channel as GramJs.Channel).username, avatarHash: buildAvatarHash((channel as GramJs.Channel).photo), }, diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index 1bb4ce5b6..d71a306c5 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -108,9 +108,10 @@ export async function fetchInlineBotResults({ } export async function sendInlineBotResult({ - chat, resultId, queryId, replyingTo, sendAs, isSilent, scheduleDate, + chat, replyingToTopId, resultId, queryId, replyingTo, sendAs, isSilent, scheduleDate, }: { chat: ApiChat; + replyingToTopId?: number; resultId: string; queryId: string; replyingTo?: number; @@ -127,6 +128,7 @@ export async function sendInlineBotResult({ peer: buildInputPeer(chat.id, chat.accessHash), id: resultId, scheduleDate, + ...(replyingToTopId && { topMsgId: replyingToTopId }), ...(isSilent && { silent: true }), ...(replyingTo && { replyToMsgId: replyingTo }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), @@ -156,6 +158,7 @@ export async function requestWebView({ url, startParam, replyToMessageId, + threadId, theme, sendAs, isFromBotMenu, @@ -166,6 +169,7 @@ export async function requestWebView({ url?: string; startParam?: string; replyToMessageId?: number; + threadId?: number; theme?: ApiThemeParameters; sendAs?: ApiUser | ApiChat; isFromBotMenu?: boolean; @@ -180,6 +184,7 @@ export async function requestWebView({ themeParams: theme ? buildInputThemeParams(theme) : undefined, fromBotMenu: isFromBotMenu || undefined, platform: 'webz', + ...(threadId && { topMsgId: threadId }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), })); @@ -216,6 +221,7 @@ export function prolongWebView({ bot, queryId, replyToMessageId, + threadId, sendAs, }: { isSilent?: boolean; @@ -223,6 +229,7 @@ export function prolongWebView({ bot: ApiUser; queryId: string; replyToMessageId?: number; + threadId?: number; sendAs?: ApiUser | ApiChat; }) { return invokeRequest(new GramJs.messages.ProlongWebView({ @@ -231,6 +238,7 @@ export function prolongWebView({ bot: buildInputPeer(bot.id, bot.accessHash), queryId: BigInt(queryId), replyToMsgId: replyToMessageId, + ...(threadId && { topMsgId: threadId }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), })); } diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 022e43ed4..d3a48f0f4 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -1,4 +1,4 @@ -import type BigInt from 'big-integer'; +import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; import type { OnApiUpdate, @@ -12,11 +12,20 @@ import type { ApiChatBannedRights, ApiChatAdminRights, ApiGroupCall, - ApiUserStatus, ApiPhoto, ApiChatReactions, + ApiUserStatus, + ApiPhoto, + ApiTopic, + ApiChatReactions, } from '../../types'; import { - DEBUG, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SERVICE_NOTIFICATIONS_USER_ID, ALL_FOLDER_ID, MAX_INT_32, + DEBUG, + ARCHIVED_FOLDER_ID, + MEMBERS_LOAD_SLICE, + SERVICE_NOTIFICATIONS_USER_ID, + ALL_FOLDER_ID, + MAX_INT_32, + TOPICS_SLICE, } from '../../../config'; import { invokeRequest, uploadFile } from './client'; import { @@ -29,6 +38,7 @@ import { buildApiChatBotCommands, buildApiChatSettings, buildApiChatReactions, + buildApiTopic, } from '../apiBuilders/chats'; import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages'; import { buildApiUser, buildApiUsersAndStatuses } from '../apiBuilders/users'; @@ -323,11 +333,13 @@ export function saveDraft({ chat, text, entities, + threadId, replyToMsgId, }: { chat: ApiChat; text: string; entities?: ApiMessageEntity[]; + threadId?: number; replyToMsgId?: number; }) { return invokeRequest(new GramJs.messages.SaveDraft({ @@ -337,13 +349,15 @@ export function saveDraft({ entities: entities.map(buildMtpMessageEntity), }), replyToMsgId, + topMsgId: threadId, })); } -export function clearDraft(chat: ApiChat) { +export function clearDraft(chat: ApiChat, threadId?: number) { return invokeRequest(new GramJs.messages.SaveDraft({ peer: buildInputPeer(chat.id, chat.accessHash), message: '', + ...(threadId && { topMsgId: threadId }), })); } @@ -557,6 +571,30 @@ export async function updateChatMutedState({ }); } +export async function updateTopicMutedState({ + chat, topicId, isMuted, +}: { + chat: ApiChat; topicId: number; isMuted: boolean; serverTimeOffset: number; + +}) { + await invokeRequest(new GramJs.account.UpdateNotifySettings({ + peer: new GramJs.InputNotifyForumTopic({ + peer: buildInputPeer(chat.id, chat.accessHash), + topMsgId: topicId, + }), + settings: new GramJs.InputPeerNotifySettings({ muteUntil: isMuted ? MAX_INT_32 : 0 }), + })); + + onUpdate({ + '@type': 'updateTopicNotifyExceptions', + chatId: chat.id, + topicId, + isMuted, + }); + + // TODO[forums] Request forum topic thread update +} + export async function createChannel({ title, about = '', users, }: { @@ -1234,7 +1272,7 @@ function updateLocalDb(result: ( GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs | GramJs.messages.ChatFull | GramJs.contacts.Found | GramJs.contacts.ResolvedPeer | GramJs.channels.ChannelParticipants | - GramJs.messages.Chats | GramJs.messages.ChatsSlice | GramJs.TypeUpdates + GramJs.messages.Chats | GramJs.messages.ChatsSlice | GramJs.TypeUpdates | GramJs.messages.ForumTopics )) { if ('users' in result) { addEntitiesWithPhotosToLocalDb(result.users); @@ -1283,3 +1321,168 @@ export function toggleIsProtected({ enabled: isProtected, }), true); } + +export function toggleForum({ + chat, isEnabled, +}: { chat: ApiChat; isEnabled: boolean }) { + const { id, accessHash } = chat; + + return invokeRequest(new GramJs.channels.ToggleForum({ + channel: buildInputPeer(id, accessHash), + enabled: isEnabled, + }), true); +} + +export async function fetchTopics({ + chat, query, offsetTopicId, offsetId, offsetDate, limit = TOPICS_SLICE, +}: { + chat: ApiChat; + query?: string; + offsetTopicId?: number; + offsetId?: number; + offsetDate?: number; + limit?: number; +}): Promise<{ + topics: ApiTopic[]; + messages: ApiMessage[]; + users: ApiUser[]; + chats: ApiChat[]; + count: number; + shouldOrderByCreateDate?: boolean; + draftsById: Record>; + readInboxMessageIdByTopicId: Record; + } | undefined> { + const { id, accessHash } = chat; + + const result = await invokeRequest(new GramJs.channels.GetForumTopics({ + channel: buildInputPeer(id, accessHash), + limit, + q: query, + offsetTopic: offsetTopicId, + offsetId, + offsetDate, + })); + + if (!result) return undefined; + + updateLocalDb(result); + + const { count, orderByCreateDate } = result; + + const topics = result.topics.map(buildApiTopic).filter(Boolean); + const messages = result.messages.map(buildApiMessage).filter(Boolean); + const users = result.users.map(buildApiUser).filter(Boolean); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); + const draftsById = result.topics.reduce((acc, topic) => { + if (topic instanceof GramJs.ForumTopic && topic.draft) { + acc[topic.id] = buildMessageDraft(topic.draft); + } + return acc; + }, {} as Record>); + const readInboxMessageIdByTopicId = result.topics.reduce((acc, topic) => { + if (topic instanceof GramJs.ForumTopic && topic.readInboxMaxId) { + acc[topic.id] = topic.readInboxMaxId; + } + return acc; + }, {} as Record); + + return { + topics, + messages, + users, + chats, + // Include general topic + count: count + 1, + shouldOrderByCreateDate: orderByCreateDate, + draftsById, + readInboxMessageIdByTopicId, + }; +} + +export async function fetchTopicById({ + chat, topicId, +}: { + chat: ApiChat; + topicId: number; +}): Promise<{ + topic: ApiTopic; + messages: ApiMessage[]; + users: ApiUser[]; + chats: ApiChat[]; + } | undefined> { + const { id, accessHash } = chat; + + const result = await invokeRequest(new GramJs.channels.GetForumTopicsByID({ + channel: buildInputPeer(id, accessHash), + topics: [topicId], + })); + + if (!result?.topics.length || !(result.topics[0] instanceof GramJs.ForumTopic)) { + return undefined; + } + + updateLocalDb(result); + + const messages = result.messages.map(buildApiMessage).filter(Boolean); + const users = result.users.map(buildApiUser).filter(Boolean); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); + + return { + topic: buildApiTopic(result.topics[0])!, + messages, + users, + chats, + }; +} + +export function deleteTopic({ + chat, topicId, +}: { + chat: ApiChat; + topicId: number; +}) { + const { id, accessHash } = chat; + + return invokeRequest(new GramJs.channels.DeleteTopicHistory({ + channel: buildInputPeer(id, accessHash), + topMsgId: topicId, + }), true); +} + +export function togglePinnedTopic({ + chat, topicId, isPinned, +}: { + chat: ApiChat; + topicId: number; + isPinned: boolean; +}) { + const { id, accessHash } = chat; + + return invokeRequest(new GramJs.channels.UpdatePinnedForumTopic({ + channel: buildInputPeer(id, accessHash), + topicId, + pinned: isPinned, + }), true); +} + +export function editTopic({ + chat, topicId, title, iconEmojiId, isClosed, isHidden, +}: { + chat: ApiChat; + topicId: number; + title?: string; + iconEmojiId?: string; + isClosed?: boolean; + isHidden?: boolean; +}) { + const { id, accessHash } = chat; + + return invokeRequest(new GramJs.channels.EditForumTopic({ + channel: buildInputPeer(id, accessHash), + topicId, + title, + iconEmojiId: iconEmojiId ? BigInt(iconEmojiId) : undefined, + closed: isClosed, + hidden: isHidden, + }), true); +} diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index 8b4c1558e..a4b0adcef 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -52,6 +52,7 @@ export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs) const { userAgent, platform, sessionData, isTest, isMovSupported, isWebmSupported, maxBufferSize, webAuthToken, dcId, + mockScenario, } = initialArgs; const session = new sessions.CallbackSession(sessionData, onSessionUpdate); @@ -104,6 +105,7 @@ export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs) shouldThrowIfUnauthorized: Boolean(sessionData), webAuthToken, webAuthTokenFailed: onWebAuthTokenFailed, + mockScenario, }); } catch (err: any) { // eslint-disable-next-line no-console diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 354631586..6f5590e74 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -12,14 +12,15 @@ export { export { fetchChats, fetchFullChat, searchChats, requestChatUpdate, fetchChatSettings, - saveDraft, clearDraft, fetchChat, updateChatMutedState, + saveDraft, clearDraft, fetchChat, updateChatMutedState, updateTopicMutedState, createChannel, joinChannel, deleteChatUser, deleteChat, leaveChannel, deleteChannel, createGroupChat, editChatPhoto, toggleChatPinned, toggleChatArchived, toggleDialogUnread, setChatEnabledReactions, fetchChatFolders, editChatFolder, deleteChatFolder, sortChatFolders, fetchRecommendedChatFolders, getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected, - getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, + getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, fetchTopics, deleteTopic, togglePinnedTopic, + editTopic, toggleForum, fetchTopicById, } from './chats'; export { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 70f199998..2e426f13b 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -93,7 +93,7 @@ export async function fetchMessages({ result = await invokeRequest(new RequestClass({ peer: buildInputPeer(chat.id, chat.accessHash), ...(threadId !== MAIN_THREAD_ID && { - msgId: threadId, + msgId: Number(threadId), }), ...(offsetId && { // Workaround for local message IDs overflowing some internal `Buffer` range check @@ -255,6 +255,7 @@ export function sendMessage( sendAs, serverTimeOffset, ); + onUpdate({ '@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage', id: localMessage.id, @@ -280,7 +281,15 @@ export function sendMessage( if (groupedId) { return sendGroupedMedia({ - chat, text, entities, replyingTo, attachment: attachment!, groupedId, isSilent, scheduledAt, + chat, + text, + entities, + replyingTo, + replyingToTopId, + attachment: attachment!, + groupedId, + isSilent, + scheduledAt, }, randomId, localMessage, onProgress); } @@ -328,6 +337,7 @@ export function sendMessage( ...(isSilent && { silent: isSilent }), ...(scheduledAt && { scheduleDate: scheduledAt }), ...(replyingTo && { replyToMsgId: replyingTo }), + ...(replyingToTopId && { topMsgId: replyingToTopId }), ...(media && { media }), ...(noWebPage && { noWebpage: noWebPage }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), @@ -349,6 +359,7 @@ function sendGroupedMedia( text, entities, replyingTo, + replyingToTopId, attachment, groupedId, isSilent, @@ -359,6 +370,7 @@ function sendGroupedMedia( text?: string; entities?: ApiMessageEntity[]; replyingTo?: number; + replyingToTopId?: number; attachment: ApiAttachment; groupedId: string; isSilent?: boolean; @@ -434,6 +446,7 @@ function sendGroupedMedia( peer: buildInputPeer(chat.id, chat.accessHash), multiMedia: Object.values(singleMediaByIndex), // Object keys are usually ordered replyToMsgId: replyingTo, + ...(replyingToTopId && { topMsgId: replyingToTopId }), ...(isSilent && { silent: isSilent }), ...(scheduledAt && { scheduleDate: scheduledAt }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), @@ -628,9 +641,10 @@ export async function pinMessage({ }), true); } -export async function unpinAllMessages({ chat }: { chat: ApiChat }) { +export async function unpinAllMessages({ chat, threadId }: { chat: ApiChat; threadId?: number }) { await invokeRequest(new GramJs.messages.UnpinAllMessages({ peer: buildInputPeer(chat.id, chat.accessHash), + ...(threadId && { topMsgId: threadId }), }), true); } @@ -834,14 +848,18 @@ export async function requestThreadInfoUpdate({ }: { chat: ApiChat; threadId: number; }) { + if (threadId === MAIN_THREAD_ID) { + return undefined; + } + const [topMessageResult, repliesResult] = await Promise.all([ invokeRequest(new GramJs.messages.GetDiscussionMessage({ peer: buildInputPeer(chat.id, chat.accessHash), - msgId: threadId, + msgId: Number(threadId), })), invokeRequest(new GramJs.messages.GetReplies({ peer: buildInputPeer(chat.id, chat.accessHash), - msgId: threadId, + msgId: Number(threadId), offsetId: 1, addOffset: -1, limit: 1, @@ -882,6 +900,14 @@ export async function requestThreadInfoUpdate({ }); }); + if (chat.isForum) { + onUpdate({ + '@type': 'updateTopic', + chatId: chat.id, + topicId: threadId, + }); + } + return { discussionChatId, }; @@ -928,9 +954,9 @@ export async function searchMessagesLocal({ const result = await invokeRequest(new GramJs.messages.Search({ peer: buildInputPeer(chat.id, chat.accessHash), + topMsgId: topMessageId, filter, q: query || '', - topMsgId: topMessageId, minDate, maxDate, ...pagination, @@ -1154,6 +1180,7 @@ export async function fetchExtendedMedia({ export async function forwardMessages({ fromChat, toChat, + toThreadId, messages, serverTimeOffset, isSilent, @@ -1166,6 +1193,7 @@ export async function forwardMessages({ }: { fromChat: ApiChat; toChat: ApiChat; + toThreadId?: number; messages: ApiMessage[]; serverTimeOffset: number; isSilent?: boolean; @@ -1180,9 +1208,16 @@ export async function forwardMessages({ const randomIds = messages.map(generateRandomBigInt); messages.forEach((message, index) => { - const localMessage = buildLocalForwardedMessage( - toChat, message, serverTimeOffset, scheduledAt, noAuthors, noCaptions, isCurrentUserPremium, - ); + const localMessage = buildLocalForwardedMessage({ + toChat, + toThreadId, + message, + serverTimeOffset, + scheduledAt, + noAuthors, + noCaptions, + isCurrentUserPremium, + }); localDb.localMessages[String(randomIds[index])] = localMessage; onUpdate({ @@ -1202,6 +1237,7 @@ export async function forwardMessages({ silent: isSilent || undefined, dropAuthor: noAuthors || undefined, dropMediaCaptions: noCaptions || undefined, + ...(toThreadId && { topMsgId: toThreadId }), ...(scheduledAt && { scheduleDate: scheduledAt }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), }), true); @@ -1281,13 +1317,14 @@ function updateLocalDb(result: ( }); } -export async function fetchPinnedMessages({ chat }: { chat: ApiChat }) { +export async function fetchPinnedMessages({ chat, threadId }: { chat: ApiChat; threadId: number }) { const result = await invokeRequest(new GramJs.messages.Search( { peer: buildInputPeer(chat.id, chat.accessHash), filter: new GramJs.InputMessagesFilterPinned(), q: '', limit: PINNED_MESSAGES_LIMIT, + topMsgId: threadId, }, )); diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 15bd11e6d..9bce47c96 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -39,7 +39,7 @@ import { } from './gramjsBuilders'; import localDb from './localDb'; import { omitVirtualClassFields } from './apiBuilders/helpers'; -import { DEBUG } from '../../config'; +import { DEBUG, GENERAL_TOPIC_ID } from '../../config'; import { addMessageToLocalDb, addEntitiesWithPhotosToLocalDb, @@ -49,7 +49,12 @@ import { log, swapLocalInvoiceMedia, } from './helpers'; -import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc'; +import { + buildApiNotifyException, + buildApiNotifyExceptionTopic, + buildPrivacyKey, + buildPrivacyRules, +} from './apiBuilders/misc'; import { buildApiPhoto, buildApiUsernames } from './apiBuilders/common'; import { buildApiGroupCall, @@ -149,6 +154,13 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { let message: ApiMessage | undefined; let shouldForceReply: boolean | undefined; + // eslint-disable-next-line no-underscore-dangle + const entities = update._entities; + if (entities) { + addEntitiesWithPhotosToLocalDb(entities); + dispatchUserAndChatUpdates(entities); + } + if (update instanceof GramJs.UpdateShortChatMessage) { message = buildApiMessageFromShortChat(update); } else if (update instanceof GramJs.UpdateShortMessage) { @@ -174,13 +186,6 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { && (!update.message.replyMarkup.selective || message.isMentioned); } - // eslint-disable-next-line no-underscore-dangle - const entities = update._entities; - if (entities) { - addEntitiesWithPhotosToLocalDb(entities); - dispatchUserAndChatUpdates(entities); - } - if (update instanceof GramJs.UpdateNewScheduledMessage) { onUpdate({ '@type': sentMessageIds.has(message.id) ? 'updateScheduledMessage' : 'newScheduledMessage', @@ -282,6 +287,23 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { }, }); } + } else if (action instanceof GramJs.MessageActionTopicEdit) { + const { replyTo } = update.message; + const { + replyToMsgId, replyToTopId, forumTopic: isTopicReply, + } = replyTo || {}; + const topicId = !isTopicReply ? GENERAL_TOPIC_ID : replyToTopId || replyToMsgId || GENERAL_TOPIC_ID; + + onUpdate({ + '@type': 'updateTopic', + chatId: getApiChatIdFromMtpPeer(update.message.peerId!), + topicId, + }); + } else if (action instanceof GramJs.MessageActionTopicCreate) { + onUpdate({ + '@type': 'updateTopics', + chatId: getApiChatIdFromMtpPeer(update.message.peerId!), + }); } } } else if ( @@ -655,6 +677,16 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { '@type': 'updateNotifyExceptions', ...buildApiNotifyException(update.notifySettings, update.peer.peer, serverTimeOffset), }); + } else if ( + update instanceof GramJs.UpdateNotifySettings + && update.peer instanceof GramJs.NotifyForumTopic + ) { + onUpdate({ + '@type': 'updateTopicNotifyExceptions', + ...buildApiNotifyExceptionTopic( + update.notifySettings, update.peer.peer, update.peer.topMsgId, serverTimeOffset, + ), + }); } else if ( update instanceof GramJs.UpdateUserTyping || update instanceof GramJs.UpdateChatUserTyping @@ -684,6 +716,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { onUpdate({ '@type': 'updateChatTypingStatus', id, + threadId: update.topMsgId, typingStatus: buildChatTypingStatus(update, serverTimeOffset), }); } else if (update instanceof GramJs.UpdateChannel) { @@ -907,6 +940,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { onUpdate({ '@type': 'draftMessage', chatId: getApiChatIdFromMtpPeer(update.peer), + threadId: update.topMsgId, ...buildMessageDraft(update.draft), }); } else if (update instanceof GramJs.UpdateContactsReset) { @@ -1042,6 +1076,19 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { dispatchUserAndChatUpdates(entities); } onUpdate({ '@type': 'updateConfig' }); + } else if (update instanceof GramJs.UpdateChannelPinnedTopic) { + onUpdate({ + '@type': 'updatePinnedTopic', + chatId: buildApiPeerId(update.channelId, 'channel'), + topicId: update.topicId, + isPinned: Boolean(update.pinned), + }); + } else if (update instanceof GramJs.UpdateChannelPinnedTopics) { + onUpdate({ + '@type': 'updatePinnedTopicsOrder', + chatId: buildApiPeerId(update.channelId, 'channel'), + order: update.order || [], + }); } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; log('UNEXPECTED UPDATE', params); diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 3ce421db3..21c31e823 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -15,7 +15,7 @@ export interface ApiChat { id: string; folderId?: number; type: ApiChatType; - title?: string; + title: string; hasUnreadMark?: boolean; lastMessage?: ApiMessage; lastReadOutboxMessageId?: number; @@ -39,6 +39,10 @@ export interface ApiChat { draftDate?: number; isProtected?: boolean; fakeType?: ApiFakeType; + isForum?: boolean; + topics?: Record; + topicsCount?: number; + orderedPinnedTopicIds?: number[]; // Calls isCallActive?: boolean; @@ -64,8 +68,6 @@ export interface ApiChat { settings?: ApiChatSettings; // Obtained from GetFullChat / GetFullChannel fullInfo?: ApiChatFullInfo; - // Obtained with UpdateUserTyping or UpdateChatUserTyping updates - typingStatus?: ApiTypingStatus; joinRequests?: ApiChatInviteImporter[]; isJoinToSend?: boolean; @@ -137,6 +139,7 @@ export interface ApiChatAdminRights { addAdmins?: true; anonymous?: true; manageCall?: true; + manageTopics?: true; } export interface ApiChatBannedRights { @@ -153,6 +156,7 @@ export interface ApiChatBannedRights { inviteUsers?: true; pinMessages?: true; untilDate?: number; + manageTopics?: true; } export interface ApiRestrictionReason { @@ -189,3 +193,25 @@ export interface ApiSendAsPeerId { id: string; isPremium?: boolean; } + +export interface ApiTopic { + id: number; + isClosed?: boolean; + isPinned?: boolean; + isHidden?: boolean; + isOwner?: boolean; + // eslint-disable-next-line max-len + // TODO[forums] https://github.com/telegramdesktop/tdesktop/blob/1aece79a471d99a8b63d826b1bce1f36a04d7293/Telegram/SourceFiles/data/data_forum_topic.cpp#L318 + isMin?: boolean; + date: number; + title: string; + iconColor: number; + iconEmojiId?: string; + lastMessageId: number; + unreadCount: number; + unreadMentionsCount: number; + unreadReactionsCount: number; + fromId: string; + + isMuted?: boolean; +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 524e36ad4..ba689a7a4 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -259,7 +259,7 @@ export interface ApiAction { text: string; targetUserIds?: string[]; targetChatId?: string; - type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'other'; + type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'topicCreate' | 'other'; photo?: ApiPhoto; amount?: number; currency?: string; @@ -390,6 +390,7 @@ export interface ApiMessage { replyToChatId?: string; replyToMessageId?: number; replyToTopMessageId?: number; + isTopicReply?: true; sendingState?: 'messageSendingStatePending' | 'messageSendingStateFailed'; forwardInfo?: ApiMessageForwardInfo; isDeleting?: boolean; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index f3db42632..127248d26 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -12,6 +12,7 @@ export interface ApiInitialArgs { maxBufferSize?: number; webAuthToken?: string; dcId?: number; + mockScenario?: string; } export interface ApiOnProgress { @@ -174,6 +175,7 @@ export interface ApiAppConfig { premiumPromoOrder: string[]; defaultEmojiStatusesStickerSetId: string; maxUniqueReactions: number; + topicsPinnedLimit: number; maxUserReactionsDefault: number; maxUserReactionsPremium: number; limits: Record; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index eebc417c8..093e47eba 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -121,6 +121,7 @@ export type ApiUpdateChatInbox = { export type ApiUpdateChatTypingStatus = { '@type': 'updateChatTypingStatus'; id: string; + threadId?: number; typingStatus: ApiTypingStatus | undefined; }; @@ -184,7 +185,7 @@ export type ApiUpdateNewScheduledMessage = { '@type': 'newScheduledMessage'; chatId: string; id: number; - message: Partial; + message: ApiMessage; }; export type ApiUpdateNewMessage = { @@ -309,6 +310,7 @@ export type ApiUpdateResetMessages = { export type ApiUpdateDraftMessage = { '@type': 'draftMessage'; chatId: string; + threadId?: number; formattedText?: ApiFormattedText; date?: number; replyingToId?: number; @@ -433,6 +435,11 @@ export type ApiUpdateNotifyExceptions = { '@type': 'updateNotifyExceptions'; } & ApiNotifyException; +export type ApiUpdateTopicNotifyExceptions = { + '@type': 'updateTopicNotifyExceptions'; + topicId: number; +} & ApiNotifyException; + export type ApiUpdateTwoFaStateWaitCode = { '@type': 'updateTwoFaStateWaitCode'; length: number; @@ -563,6 +570,30 @@ export type ApiUpdateTranscribedAudio = { isPending?: boolean; }; +export type ApiUpdatePinnedTopic = { + '@type': 'updatePinnedTopic'; + topicId: number; + chatId: string; + isPinned: boolean; +}; + +export type ApiUpdatePinnedTopicsOrder = { + '@type': 'updatePinnedTopicsOrder'; + chatId: string; + order: number[]; +}; + +export type ApiUpdateTopic = { + '@type': 'updateTopic'; + chatId: string; + topicId: number; +}; + +export type ApiUpdateTopics = { + '@type': 'updateTopics'; + chatId: string; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -587,7 +618,8 @@ export type ApiUpdate = ( ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted | ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState | ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus | - ApiUpdateMessageExtendedMedia | ApiUpdateConfig + ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic | + ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 40b6fdb59e8934e0e2b7177bad51270dc9856124..a9fcf97cd4ce94ab0630b35e79351fa8db833c03 100644 GIT binary patch delta 1852 zcmZWpeN0nV6urAFWzfu`+%0xmSQY|{oob(C<+XrFu;m{*dfF(VyF^jfX1z4 z*~~(8K}!~A%(6NDFya<7)F>m4IXC|y#4sV4EgOHBg=}QW;yxU6xO-bcmfgH_?m6fF z&OPs*-+gcJEj0WN()ZR?Q~(I@xaR;$GJidHM(>J#yC>4#1pw^8kp?2h#%~G_v>zq0 zaqP>VOVWYPWBYOJE&#V55s`Iy!yWtD_X03+31cK8wzKBP9XN<%^VoL~k=>WNA}^^35+>O zMq&}i`BPzl6g$@BkMfaH+JAo(R<9`DP_fmu$wTc>C3HfNqs@_G!iH8vFm zC4PU%WJI*UF&ec@j$~>Uv5ZTuWEhTNRa}~aV`+|NIYk<$VsVU7%3Tb`sAWhlQ)?Jb zppnrO^7~7!+eOjg5Jmg1L=gx15-ZE+lXzOeQBa{^7)VhxC6oD;Dnvyg#o-bP3J(RY zfFTr8Dg81T#!!%96bg(`(0mfypLo;d>BQD6B?xEup7cDMM+4|p;08eFlM-#ihfTC2 znb)(ptGF4tkTis}SPMy;_d0PfkMCi5U8&0%X={r(U8OpnWwdIC-WZUAqFJpKr*e7mYn(DD#^++_wMjfc@t=Jr6yYlXThSUNhu ze{?xYfjJOH>!`m#2s8nZX>;0hFp~*h3ZZ}12*5(7znrvg8y#q7lR+|*d8Db0u&3ncH&un7|4x6|m3ebD4d9)k)7 z(U5}6e==$sLIt@Gv45KEu}vTH8y2q8Y6c>B6;UijBiP2`dqc|6tK~?wt)$PZ^&pBJ z^Q6ajzsza1TqUQ`aL`6{CGl5wk1lGs<&Wcw+lhW*0~Yjy5!6qO01YsMY)}Y-U?(nN zW0Um;Oq!JEbGo26?s_xgu0139zb?YZ%!pHI&%mE#mQNyV$C<^h(Z)vC?lnSqSszz3 zM8mjVcCEEtYr``ua6ycN61mifD3FvFGAm*}F`eQwkeEe&mRtov%oPQUPT27uFz)Q* z*Pnd0TU+!yP~u-A9zWb%yuUKX>c{=?R_yKZ01Kw^a?j}G_#l;ptvM{N=h7bwf1VNj zQ7*|3%70OebAw7s8B*R+E~q|HYt$1Oi>5(auf3F{N*d;U{2Tmbeo43@Eb0n$!+Mi` zD*2?L$S`jVn`qOp>49lk>`FPCs!x40ttaiinKhrcSS_uV$@Kj6Bk99doAvz+!PaP- zu>EA8c9c8DGtXw;cNRHck2+_wLRrJ>Q(VAxA-g7fY(qhgHRnR^+{U4e<8G(>koymh z!871l@*ecg`_g=4d8WK;5+{{Q=cFnB-h4KHbN-?HCz~cWEfq`@h6|^PY(-HRx-E_^zm+bOwUk{fFDifX(us1lII j^MjV|mK)*haCdn2m6NTY^?2*j?jv!MZjDY&+=2fBX}^J_ delta 989 zcmZ3|$2_5xS*+aO&5ePP0SFFFVBiMRE)0y51&<3))D)|~o}8Olz`($m0h9;gMoqR3J9OP;k7v@KN+abZhs8W&>RqEJZ8|6n^*y~_`nIE zMg=fF%y=g+F*g+`_5!G;4TNWUasDpIFD?Q4r(ohAf5r`yeHgtNH*B84xRtM-h0%%O zEYm)wg$z;*h77h0oS%LHg#k#=_^U}9`+5_jw(i^TXr+W5s_Gy@aRQzwC* z>SGWD1&b;h*!9*3k1?94X)`LZi3-|-JtN2%&n2uY|3qF_m}}ueMoV%2C;a05EL<%8 zCt10<88Z}gbQJ#0;O1tHiejt;+4XOSKQpsGqX)xgJ)y;`lo^3R55zL=tXt#xZN4&a zvj7Dc818SL+zg{9pE+R8xOFniK|3}^Cl&@qr_CM*CooNZaagf_9s3gw3643OSzLBp z8@L&`3%IB8#PICl&Eq}8$He!KKa78uK#;&AK?A`E!4*PLLbHTk2nPut6R8n35q%}r zC(b3lNg_z1Nn(wpoRovqA!#ES6`4AjEwUc6=j3?gs^p%@E64}QUr_K-IH1I$l&3U9 zxk34pN|vgFYMtsUHJy637Ig;oAk7n6>$JVJf9YiCywE+T=cV6f;AD_xaK%u@aEaj& zBMYMy#uCPT#-~g&Opci*neH(CWaeg8V=iRA#r&OxjKwm`D$5mCdDdLkHP#nwGHhPh zM%lL6KC!!CUt@pG;gaJbr)ka{&H>J^T$Z_NxqfjwzMK3M{2BZM{Lckc1@Z*;1bz!@3OW}o7rZGXB~&VOTbN7Oxp1rS oRS_H!O;JiwDbcrLlww+9O~4s28wZX}8!DhBi-@5hoPHm1E6R|)dH2NCb z^=_iq4aq1}{JqZGf0U=GM2Q8wbir0Pprv5|BM^fiuuW$WLAs!=YqN>(=T8s4eo&{C zuGe8ZpgChv`VXN*<7$WP_P}KA_N5gtE<$vcJ3|$J2YOZg7ErlihyJ%3<%4LsP|6!w>CSW~<=Kq}_EG)iY$| z%)EXd`c39Y4*P4@qK|H zHKL#yKM3ONVkjS4trhXgWg zIg%}Tlsf?cP!arJZumHOzn}JB{{q&c3B=hG1`vS2sK6oB03gUe)(5=G492gvKMp=G z&@Vuqa=?Fn5HnONfPNl9K8^`*6$I(^p5P-Aaiu5TDi8ufcj7?&ECrANT&X?z|IG2P z!(o9iSekS^N<47`r3!>c07OVa2$XP544+jZi2LUWmI>?udA)LU)5R}0IQV<9VLXVQM5aAFc;dB650w8ALIL9DkGsd2U4iwXy zx{*#a0x|RO0M7(~Q3a?ReK0VRP>lX)L>AsQ5?BEt010P=#R!B$1OTkgMrC!tp}?p) zTTx;@8{ z;4es2hk^s(W$Oi30P;M_w>d}P16-7obgJArJtb7IA}gx0YN7EAXVuAGp?o1{zCRav zmwDVOThKy)5Eo?tAy6VDfo85DSIR#r)k_|_BF{ZxpRs#|y8xhz8dhwr}fP!^jE1w;9*ULKq)`Nn}v>R4Hu<`2R1^YZcZdgoQv%XlWssWL(AF z`bxK%T|SYE-d|NJu=rE;4?i4YP+!WEHg#heQy zYE=PMy{c6<3Bk1=r2rq7W6o89i}}2wC_imdvT@0M{??Y1DwJJxQbIkWiI> zMUgn(J`$&ji7D8op&)|(CJdEmW@fPL&@KD)d)3l~m?#WCV4cVdDGD>+C_*qi@CaO! z8;~R-U{a?Q)ADIy*NoE+(@xViwSx+PFj7W{Bt&2>GUX5|VrA;x%>+%wImvhNl@l0!&oOabeyc&{i`V|wk&WK4qy0UMXk0c}I(> z(IAL!rX=5vZ4DI-+UA{ z_kwz1ls)tg5>1=t8cNJoCmpL0KQ259jk-E>FS+)z#YHW?*Yk#KI`_aeb!Njn*@T&h zrJe0|G*m+vp{*;r??V|_Im#p?2%ilZXL=HtLX+o%;GT8HF-Tc6(er6{A!WQYd%yY^ zwEo$HbJ2DCtwE&p!x-~eC2qB{y5WYovUx<(IGK?_&wVCP~jHU=PX?+DlPg_=SMGOI!o~~ z&LG&6n&gd5sZQ-;qba!=PHb2;lZgOM+PJ4T%-e+;a-bh)Fma%y2x$2Y40Emsoc~tY zIY#*t@c_Q*vB2*G6g(|_NT?DY9THpT1ZKtj`p&&K(cI4|Syp7Do4r*no|idMWCPxu z!T5+4lF*Ni(T=ZtJg*&TLFKT62{r4Sh!t++i+2vSDZVn8K4-O{uK9ct^`~8ElGR6z z1yhk&BA!7qg1r1`&tdarYn10oK3^K@<{5g`9vAs3yi}PqooQi3?w9{YNqo>|pb{}# zsx!|-#kHa^LOD1f2h35it)eI!5>9!=I?SfxOQn_fy07i*do}@~-3zl8j@%a6(9TZ_ zwRH6+Pl;=zz1_hak${T%abHusnA!YPMT#i}F=>Ky83#*qHuzje8qY@^J{fGK${4m3 z@7psu2;R>6qj@1uDR}arw1EX4k%$&btfo@ComOic_O$#*=(210kqTk-sR0q-24cqr z9OOifW~la1trYpMo17KIP?w$Dc+bCY>D>7Pc|MFi=cEH@LvezEbE>3@WZps7~7C&6@QgM6J&K#?9@CdWnLPO1SA&DEhTiRUZ15ZWtQFx zUXf7rBhDoxe##@zCQ6B114h8Tp%Ap8fg75Pe@7jRm>nl@sYGaO7y^xl#WXvC$8b#G z)Fg^CT(M}^RKEq~LDB@b)gS$w!_kHvO=nG0#8@FBRXv_bM0_c&5}#1fg&&$f&vNqM z6^Ua+InY3}z6QjjC4PW2JL9@tE{ng|DNC&?HXfvPP!O1SuE3csq#7dO7w;O-vn|_p z!j72jV%zme%p-`WEPw&m5!36OK<3sVnrLf!1lDE*0v6VZ!LQCdmY+0%QUO z#S0iYU`*0iL6|NR>Rtw~TewvmUs8mL+Z$Fs{1UFa!bXOJX1gr| z&EqOl3bBsvczk~~#j)UYJI}g@Oc9zvG0^p#Ee+<-B#CI(n#U^`BcII;ZojQvm1gX= zdeZDR4w$Ua1yN>eO<)1l#dfff{d?{rr2avR_+v%RGxV55c!06;3XA z#odq^Xy3!dQA?%;eABR&?h|Wrab*|7YiGIr_pl|38vas!QWdiP>fv*`k+6%C2a)PH zvwi1z13#+0TuzMdb2r1wKQi(n>3SbRt`*5ZxY0fiMt4p^$mwX&^M}$c!C=*Lthj9F ziV&`zRQb|b4Dm=@s`?mt6hjC#FeUYuqc10*n=c^P?;Muf z=QVEsG>o6iD7(V+jBK#cfWSqs<;3K3rK^w8e0`2a^aZS$Z1H*c9$$fmli4{frN$QD zoZ&vNAkm!hG>R!ewLfrGqamOIuLeN-=KscON*&p;GaeKC~&&OzS&LV80MYNvbOs}0Qj*K^8bf@VdST1 z%AYy_E`CxiV`jfEv4)w-x{^dbIdj3?*tE3!N3}s6em8p(sKJt6ioF zrGj2GSyp<4A0ABmDiZE-O9M(kVuiqEQ^US==RTAo=9HM>QbS@=DrdQdzC;l-yjb9M zsZqJga}h+J;@zHt-Ekh&>FJEir8r3`8D@4bU=B99XGyp=MHl8(wOc{>6i(hIfRrVJ zRMs!)gWbw&_Kk6J0h6NMLjeqh5W?>OK)IJv^$UnCRA;0$1EH6zV1F|ZDI@F`ljfO}A|&<9;G16Ug(T4iqryR=6=IKVc}M4j8WH$SIShvv&+>jRi<{PEs!0kX-_ zf8CY?@}u-F$yxg;Ww0Nk7V;ZR@z4Xgo`UK&(hD2SQ(-ULAl8&8%_CLyoj3cVd29KqE6uD<3~`s;I0u#lIM2jt$CO>3Gt z%GsF>-{p+$lGZH%a?D8%7;=4nWb5yAWQ z<;uju=0C4Mp8@=Bfa;_40Zig8B#@&Zl@z7Vi zbC*l9HO;4zeeRpP)yiF1J6xIgO7GEd(P-y%y<1#ve!EfOcjTK#(!K(MG6HDdsv46hv=Fs`fl__Huz zh8I8>?@T4NckPPo6d@+sG%IxRTqJs0d}`?G>ismDp` z1RSmjWsEo|Vnk9Bb`WX@C18f2*d||5(v{Vz4|lV7(|Z@Iv25@2%ia6JIPFEkVJ(UG zF=I)4_?#&L=pDEeoQaTpMcFO9L=gg92Ac^N9P5MAg4aakz&m{h0E_@tUZMI`xRQK1 zg9peYXpVGvfIOs{Zx81;64_=$0Q4?*r~2U%?<1CMC^;v$Lz?He->qn1@}a*)s#kPk zPymC8T~dPMNv-{=Z4t&IF&|RX9;X^YYlwC`CJ@i3I1-64Y(GtCLvV;p!n0$TM-W=0 z;i>GVRR^BQd5RZ>GbNk$$SM?+H&u}|ZyRCOjmL_qAW5+7MP;7Hv}o$KS?HP{ga=Nz zg=8uhg_)T{|8uKYaF_aL&Gt`cW4x3ZEVM|Hiu+5|;zYm(k=~bI=;qHWh0g3a_lDa; zCkkeOEjt-^CO0F1`{Be&rFDkq6W^amkSD{V@{^#*k23 zp2y^_H3I&o1%e~!`gEiLX4y|J0Ubs8kE34aQNLH30cc+|q5_XaGLT4S?+=oAb@_-* zi>IH*v^Am3+a(MVE~aMzOo-G#KBMhqC~Tkinw_o(rdhDe?+6+K)iz~ZgV*f01Qh|W zB(MTwPjt~o3jYZ!1x>8ed;Skj=MdBgwfylU_KpQ`DYJe0t`-yqBd`ek6jI&6h7t203a?b%BkDoH7V zdKoFXfPny=M+tr+C4KaifT7+w2@>UJpfVrY5LW0=E|03(G+2J#nUt0P7zJ`X5={v` zu6Ju6Y_Jrqhz!{!bAm}qsJ2)(OhkRCw0^X{6bqAkT;J8sJ9n#H1DCfDL2u7*x~%5; z2z@>;?e8z~f+Z_PHO0DfRr+RKv1U{eS0R$&>g*Y`z((CWaP_n1ivqUtx7iRN=NRLE zm}v#85AXamcf^*4yCM_40ko0%)dUk`qLc+Nk7OPNU7$LJ!L4HpXm7&CT>9yKD2=sN zW@RlQgYDVML^WJ5r)1mx{MgmCg``zD!7Or)kqretYlNFh<^dX09fz2UObOEHa@wrileahmw09QG@}x9&`#f493u#pR$?Wm_nuo+j!J&W;{NbW$7N97-jU)QW1*peA}PT`bJ&ve^^qm0<3V?eol_ zXQ4ERYoWSZXZI*pmWILte^PyoJxFBlERHhZo&g>nqAeCRD9xlqnD4=F= z*snlnki(F?VX>!5d6;@n7F&xZ0IR;};jmbts$z3VR;bFPn`g1dBv!Q0D+R-#T!c#k ziC7a{HjwCWhpLIlP{(E(fWqKlai-9Db>uGOcwWAvLii}xkX`qE*+?|613(_xSAquo&x!)LlLn&^3Z*UXM6D=@F(%U-$6v+SEfoFjE9 z?7m(fC0-bx^@NS=ylpftJSd%6QzSG0JnV*Hx}}ruZS~4VGrrFf91q@@%DC+d0_aI| z$Vw%bb2{Q8EYmVqjnlMP&5bjOJV*U5?hmElc34o!NzkW7Jv{VP!oKu4N!0b>iKeEE zy{mp5I+Zl3AZey%NW8xpbx_#mr!}5bNyI4x6_Czol^O!7z9gCy(d;X9!G~Q6dc&Fy zAyLngN2N4A)H@T3`q$;$ZG@N>B(fn8zz7;!KZfp5wQ?PV8eIjXdxPq502P}b?>}Z6 zIVJX_>4DJl@s+Jx7`4VMvxT##_M!m9x_QjiL|p4`fs>;)6K)=`xnpYr0-M z0p#~NT-z__2k+xexZfkQ=h(4@Dib=Nk2-aHWdl%VNYWq~3vEDw${9QF1IL!D=xe;eIy424gIfE;=hFgbtZ`)iW-- zM<5H7b(?~I9j~7e={JxCpq?51&OeajH1&;Ag-k_QHOYj#<@6`l0G37Xk9uilTSJ$P z)`Lq*8BEINLTZ+$s3yiDpeQ+D?%)XZ)*DM@1}S7g0U5V7wz2K=Fb&0rL~i_A2ui;% z9O%z^FOp@J_*veWL9l`zB#OHx4m44Y0QVOURYMXm5PrqIqP^n1(t)I;itOMD#OzK` zzEh6ji~lp^7~X-a`@~%TmCMPXhzHRB4K45CD94$>3ME?RzTc}ZC%Bz=?1DD!8SF)lg%eO1*AVfX{+1RLf+oU>q$uDhG6J-~ zQ6Q$9%{%T!WyH5axS!)i39_+xn`>&7C2hb<=wkqep(7WRwdd3YKEOe<~V#R?ZCM}jGI7N%K_sLukh zl$}uJJ(|0FNrSXb>d7ELQkybdpu8 z9BY}-eTMT$4M6yB&?9URe)c#qS9Qb|KDBa;aXUm<7%N`BxT*S-Rbx^`j1!0BGbM9(XHM z;14@@(2w&4>5d17MoA#$p={XkNAuJ^rVF)}sjp!=Jjb_et6JU4{$|8#P+YHV^i;Eg zla(Fw%;x_+B_(!rxvw-nh4Kq4bc?}qF7(9~S#U1diaG->BAVRt3cC4!OvDShapwH; zO7`CTIjwS;^ISwgCM&(DdybM_aI-%!K~Y@9t@xpFl4w#VY%WXYTwV<8&qXlmxMlj@ zbwleaf0?yWo5!p@pFVkwL@z{_933yuR+PZ*dBx7%zDU!k;fHa#%w%7P81l<2uRDKDRz4y>C^h{YJ{TLLJ5o-F;o5lSADmk-<`FX&elLvbSE$22Qto43rVs6nxbh3gwM`qPUmPwTyW-?%xh{ zFXd&sdLFJjFTb^PW)|E3ox!<#xxFpT;rn=w53d+(=vTwB$_)%F(!$7|&oZ_UzSVFz z+DfP}v@kAez|l@JU65(J2hY}jxQ{x6V4`v_G7JYTBPpO5y+=P3Nd%2$4)eTjaTK<8 z==O%G$O<#q>yY@6N<4_ZGdOem+Z$jYn(S}mz>1PeIMJ|SzJx2(rh zS^ZeRa6oPXd?njMkmq~@q<%kW&!%C^@MH7hW%1I^>EAD~6Rd#CKK>wL2uJ*~dthF# zkC?ds33}x+xLJC`$q}r#oRS@TZYIA9Gw1+Te}GTN8+o%|V(eQ~=rOh2O~Y}1R$^_$ zw4wF4Mba0=j*xNQa%IwJ?t;ypIKvI$K!8CPsl%%yb$oq3|^V-Sa*J?2b5coBO%5@h3Fk}6Ky%__;Jq+T+8)#rzD<(WHh*)^R`>ZB)`&A@&W<#diZ;?8`FZ z8X_e?hs%DA2qCXef`z@)2QRn<`jd0A>EoUZYHi7`E2isfZAlI;hON4=VkN(BT&v4x zC-iSlQHif{7a!xCc^B1ujVJ+Fv8_lG@dU5S%g0yx&ZXS%Xgh}4&ou!XD1L7!1yqDR zZ1AWI_3;%&new+hRtObE_JuFe)BPD3*JEiY?sK^Ylxh2`{;c>UJ@RvweAAo3T8Wg# zKI4EU=+Wy(|ILAz&+^Z1kE=22?vt`;m?)q}`cK;bU_WYiTYs zQ4+kuyx^IHeX9ak8A%uv#Sh`U*s63V(JPgBazqZa@wxE}QLNsY*L5*|Wdo^?H zrY~>1A>&!WVJhL`EbYCGqim!X{irNB9XcL}ce_mVoCRGQDXgjb*!S#IuXk$@T2x}> z*re3B)USytWKS*8{%?Bo)HDyi&XZe#Qrydk;--sXlUDEsz+_F(_BKNnPPO`ReFCaI z;ZJ@pYcrT@k^}pm&JQXPckKh4xo*Cl?tji1Tq?tUjg~jNi{C&-_MipWfDJrbNY9Vn z@R{=S9rAZe2|;Q+@N5d_nVU$>xkO=b^ti5pSk*Y+huMOvlU_`Acl?UmX!r|$)dMea z7rkcLx7vHz!Y?}S*NT~(|T(4=vF%40z*5!z-jj2#S@l1H`6k; z5+YlPK6mE(N;zo#qZ+`^6 z{-nyX()l}`c=&0%XTMxMNeg>h9zFptT4N5k_wr$UEPT1nPFq;qG&924Cr0V^WZN!U zw*5>`CCTPWwCDQFXaX!__QP0XdN!QuVg@{p=RRZ^G+j^TefedZ0}J3wR<6d7?m#sl z9ItIVcMB)hV^B;(H6nH~>)gW)EI5^tB?6stw_3}0VqUeL`uQ+HX2P#DmSx`)h$FLL zNQmc{WWrSGB2PQaZ20$WIa6;HAe(0_%edHr;qBb{Po~ct=U(QMxa77p?a)_loGNFo z1UtcCVan9F;I4e)LdAkmBfYRG9}~68P9q*}@2~ZfWQBY7ZXquW(`vtzpJa>~J3w1- zd_nbDZ>e^CqhiL}@?OQaqobM32^{n}0i#S^NDk{*wB$9U0;~^b)Qg z076w=n%fJ9I9RDN4C@IsBIAIoog&l+!R&m zi)p*sQY*)*Jy@YnOj0On-wX9x@>4xNxuINs^RJCW!Bw#Zb>`~SGKh2r z2$M3>PO%cag$e`VZM|?!T=MB!WnDgr_&aRRseB*-lBx{1#1Zp#M}+2_&fy%u1Ee^? zP$SQ>2)J4VAtlED($q9f%G=w#0SEF77xJRl({VM(lP5=Qc~v?{dbgD&u3SG6Q0OW- zX#M}e77j7li4gf1&Agf&ue*FhI55%#Zv5qSZ+M{+$$LAf9e2Q+&nLWFA896npw2T^0zt= z`py{sHxZ!0eKe$Hu%Unen|L~n!hg&qPz~O+aod@JftVW6qR)Ew9aQbvJH)g1Y}hL6 z>IPJNY2n=t4Fv2V-@A8^w@p);^eg7-Cjf1+w$Z}wp@GgWhJn*LjKB$fa9gGNIvC*) zNKpg{{<_8O2cewfMg^+}onx$7G*NIA=;msk$yA9N2?QRAM<5u{O6Bs79e3&5P%Xz+ z?{PiSI7s3VmFkxVeAlm%tT2tUo}%9Tv}1XynnLE#zzI5sG=7M*5Q27WHy%MC zi26B}n!gcH#xRk23zG;?@&SSd@Q8o_+-xWSlILg-i`9d2Txw;xxE(BnUJ(aatr53o zGdv!?t$Rb~Mhj6PpwehA{Y~B5hpD!+w;~QW@U+bvj~O5D!c2LMHyT~EbyP+SxM)7a zFbqC7aKN5YUY@e>gYnHlcY^{wGjOY{{YBr{^xV?*@ksi?jHtGHGyIKBrs#m!bU8L>mPWl zqVzO99v^u{JH+vk=d;-m@4hm`QZB!i-tC@~Lz|~xlb2gUl=pcMo1HKB;S6=Ph{WSZ z4waNL;^N?kf62km|FB)}>+EE8cJ^Jj{qgz1@BiKFw)f0d{7hP8@a<7(Ww}i7qtnilQYCoP*vLy2H&OLB;+*1O_MPI@ly&W8xb5)1mdWI7z zM1|f1e`ny;b(hI&MU#`l`PoTSZQETVj2z>K{Pj>FC&Cp9E^86WOnIeH{(9mSR{^Y^cJx~peP{7Kc{NLQAf zOkT`^18FX3bzy#7UyJsXqGiY_4kv@((<_nm_DCGmsgOh>D{AXMX12P#Ig9DP9x1MU z`zN0)TPdX}@U>4GZegt0qkcF=XfhKO$N&ql00a;=9*4ujF0gnDfjmY=yHT_Qm7Wen z|GIqnFEnsU8pE2TyC7^4!?6ApUV-TMA8?nuyNA!3I;5h&2DYzW9`=mSWWoTj5u43d z<-N2j3A@Q7QD#L8%b8j3%X0+@|3alOnKJH>^|(zW^D&VvlEz#d3_x|`eic>(3-y-J@T4q0$v|Et8Y9o4?p(QUq5^VrC&IIq)HxL z)mU{8?%ZSSAD9wO4tw{>`v&SE-c#Nb?BXwS za+LGi^)M6na}&j>(&-VCh;x~b;M?)XcJ*`~YPVxdjhY^T_Nb=~pa)|b>}Ri#Kct`| zd;&s22>g+hXEp@zk?ZHaME2pd>4YmmJg@1$k%8{ML#~KPWcXj|!$G$FI|&~T>bvgj|7KbU{AbY#v~G~lteDYBmt*Irzb^|b8|0I%iy9feFxzC z5JUeN8&gN5rA5pxM~uK!r`G!iy@TDE9QzejqrF5DH3k)G^w+V;h1S4V#Q@=bV9-0l zptl0qhZ-cj3krNs7~oj;`h^cfKmd>0$eS@#%hxb1Kft`%$OO9?!q5)S40oMIhYVwC zd8$T0>Yl)?&Y5Kvyi%wWYwnKO58o%L&>OI<+-4806vy_?%QdO2%Afk=w$t~C3ul7# zrU3#WFbGc=H0gr^O{QJ~J}8ht=qaJG*bfr)ac+lQ%2PEy#6UNzCH=lja&?i4)>}jo z3WeG0F~bL(sj^tz0weUUuHQLS-{(3kv2##Md*!IBWY{&eVf%shqm&A#Z@=BH$U!@W zsL)6iooJ2%l&U>$1YLK7zQyoLxW~G@X7|n|r(V*;KNmNtj}sNqSF;l2;Z$;!PCTFI z^D>`j-Ak#}Yx3Uw+-b*>^3^+>Kv-UR(A}Wz&S#H&kx%rvKAcf|%8Akj9o^iT6$tGI zO!}as@2}jJvc}ie^7Ct4<%Fv>jz=S9f_GQG8g)AQm_OW6nxb5UAGH{&C z)d_Sy^+nWw*e-w593W=yG2){W45R(+!v2Q}pd?41RlhKcg7q~EGfVs|etuOR*{%(6 z5zIOMc4c89s|?{kQJ0m#Dw0i8Vpbs}qkCm!pe`v>!K5*4OhD%ofX_6fH$F;5W<)LbBn#TbaEx-sX6z=kPAG)fh&WXv`&unsrevDp&9_<>!rdG0KC2l6`RiRI ztRB`y2dPcX+jz%k8Zt?I=uE<$6|k+_VAGgOU=>lStTg9EQuX{YC48)AWWjo5`_`K7 z7C=y*t)ME2RGz9~5rE405RKtxl;{aeQin5BEu)`Bt#-Slt-x+JAi!9;wB5#pZ2*U( z)oS(GZL8vOytL{XB%P93QJ)GowjRZrx>t%;|DA!-dAWp?b6+$2SG8 zEg0l3g_wj7%dnHnn;<9UuCaL!-|Io3)00HS!sE%mPha=*xYro{-;1+CDDUXR$`fvr zD~b!KR36%l@~E_e;?60z235ksy-64$wb#U+tkfw(u9+0;n3IlpAt$hIbsJ17ROBr=KB;7lSD zT_N#6F5n}>we$Q-{8q2_o98#Lw$`u2##XOZu3v2~=!Z&z^F03=JC9!=!8xsBZtMxO zLZ21`_hT6O9d`FK$+K2#^$kN6x{mQ0&GHhP%W(%+TGeTqd%$+srKBlXsx zcu@S&6PL)fW1_JPQ<;8RczRMenVx}UB@ubq8Omd&8v+*x{1(jp0qP?}6S=bfFn|?g zVFoa!*1N$30NPosm$P~oOKN?>2+*OPn?gEWc(dmaL)3)n3m>QjaRBODQkcj`=NZI8fuE zaqlfR(SX&jcKrT>e8_N{b$a{wImsH!j+JwxX_R|pFVKV*>nrNA=Bbs3Q$l_QjDy_` z?(RI>7Pg&ThHnoSsn+t@z|um)4N6VPDx)XxR!Qr==^^}K(CJ(c(;tr*tOpMoaNE|D z*X^`H!!fKL)J*?>AV6@ii|^^?TP=eF#%+TJFOB}Z%6TpARo!?G;m;xc`2{Q6^n}_a zvITve@Gx*V+%>gC7K}%Nit!r0*1NKnujA_&MzarzNJQ`u;;dWyIUwVZ96@p(0MI`p z-S}l8nRtSPO}8GtmK|zZPdKz{z#I3vdvIM$JTE?aK}UzvACH+^fdI61y|WQRv#%;+ zwsspFza;3IldWL~{iRPf=00qtRx020pH+}dC5{XbkN zLdlL5#9NZf{zT!ZYpigQKS5JZBnU~{%(z>mAr4MRF&hb)T%kRH*6~>P_a;8YLwcuV3=f*>$(c$xe!@4fKS)xrD?E75S5Zb=-t!#>|u4#;| zv~)N@;)pfWpXJrTgunhw_1d=)wEyKEo|+bMBs?uG+?`@!ZrqsK+Mkq!+Dxz&Ek&B+ zC)JN!LzJq}wcyoxEsaTInB}N3I1^?rh9nPAn2yWlBz>M}G$uX_zJ0r*9KP9<5@5?ijRAa)c~eKECOc6Ta5-_o2|rAybWx=7De zhGk7ohUs}tSkZz_;`5}aUhNaL8)g&pEqcABEz$qjv0z1f{Ga{jXcD`GcCP==2=8#h z^G|GB*eO3TyGr|`cvoN2DcORwDq8?_s#z7b`a^xaaP5;PGQf<@&j;T$Hu-9Pe|sRW zy(WOP*a<24yp1n2Rn0eRDrn2nVreM#KPt`zj?~K2=0;DtnlqEa$rC5!_knJ=ZdN#) zlb+x6co1wyCK&k&VQip%5YGw3uxhjH+Mv5fh2T9f*#)nM&I8lc6{ zoM`K44rje$)+?|a964#=N>Uup;sX%E*VsKh=}{{R1Z#Yc8& zJPhB`hT_WtVqrA`+B^k|csQd`CniLMxcVs;2P&qC zq;I|nU(In=;^!I?k&sw4r zE2E=MVru{sYAM6**?ZLKRf6*?r=#2BtIJ0%Yp|2nW-^T65#knvfZ$HUgc%0A732n4N2%z6b0TaPyp@WfUAH{3x0dbb5Q`-W@d8&6SvtXmdUz-J& zL;$khj?BAnw*wXsIkUwVO!HW5q0m0$M30-zTszj}21YwK0zr9Fjd?zzB=;Yx9N+IV z4H4~#8~i0D7NU)K%6O3ewx4a;GR6N1;!%;q@Aahum4yr4vA(B%>-nxQ5!r3H4CnxXY>lBg%qy`yX8<4^fB>FA zr+A3};?pTK0v-n-$Qc5lM&R;$5CmP9UXP0B(?HWk&Z<163bVi9qE1v8hDdb>HQ<&h zly48i29oT!Eu?R7ZoP7=HPb3anUkL8B94g>ySQAAG6sq_(5zjHnRb1qBo=f}v%tHb z?KJ;q9mBgyWtZ-_gd#popTNClSvvOh-*mDGkYNCafZFf!<1uAA}hVFu$p$9hFJ2K=WahKvuRLTjbdSjz5L zKn@(WiwI;?R#Zu;1IneK)t&Xs3i23v1+LlW>=})hmepx9vW)L*_3^{~K6Y9-R$e|O zJpH1QRcY(2L@NP%)2=bmnB`obi?rOX2DX$c3u&PV!Gy0$ z5t6#?Y5M<< ziKAI=N1Q8lN_-t&>1yl3oqetPq9E5}@%%Z_ypN}UXOHd)AFK5laTDXQ%VVvi_41TV87XaFUgO)gg=K=2Ca?3;ue!YP=?uq_j8?YP$-$;Gx_ zcO-x*rBj1mgQcats|PlG>N7~Lm3hp5;0*G%*^7K}-Qho3L6xvh4o0Wh)$8ucrR}qqDI! zh(@G2g5A{Jj-=GY1-rN)r`SY@@Smh3pF@=tq%(!wLRlR^&6#UHEgV~706vH}mrl${ zwwAud*iTquj;;UT@wLjvz_#;mhj-paP7lAiBs;S6)Ey_{Z&h96vEPemd)L+GQeB#f zd)0yZFe7}#ELa4`^WtqMme?nPDMau5_lx%cgfi_e&Gdchn$MCdoRg;yA&1Z@Cyk6& z~wi$^epJ&$$Z1)X+~<$AM-3A#5%EO?%(kw)U z%u}Opu1yOLYH+pNhn>tY&oN*xF~6B^QM4?iVKWh!aRd?wgQn7;zy?#Vw*uLR>NPb4 z29=ryThB@otBiVmdu3u`WxLzZSd}O%@-7A_G&56Ei)Uv53P!w&Wy`wBPXt8g;3QG# zA?9=dPJ`f7eAg56)379A22fC*z;pZ#aqswiOY$2hsfY(LS<(-v;7U2aI|^E`wO`ja)96% zBkga6hpE(K^c1OSISyFt{5vg;&ABx#m9*e{WxQ6Kl~qv@pFr59HQIO{(A=C5uWA2! z%8P(}5oFv+?nDpu#D8Ws^|~z)4s;%rFI)(rN1DEiTF}2`%mOF5)Aty&%uwmaOBFD_ zw8@DKJ@G;L{*W4NmJ2?R0DMDu@biTEyu$pz2CAZpTpV8OJMI^W#hE0Jlr-P%E5yJu zNrK4~h-6j`$kp5Y(zG7rOtGj~Jnq+pi?_YRRe(HaPCi#Q@qCWh2oZwtE}7ce;7^|a zL_LETPGsl_4_ZD6uGMC`;DZQF#JT4aW#^L3bL69RT#<>x>%`-Ju}GXr_RyyJZ9CS7 zp-2#g43kX5w)>`~c#uNkqEJv9+5Qr>wmqm6=gkv8KOi&{+8n4#mbNBje@jH52(dQ= z{#-jub}ftT{$fEpqx;^kbSvA0zz_fs1gFfMkSmVt5z-=PU4o zW>DwFCC6-u#(cfL&5{VP4w5w{=65NW4q4hTBR**``l6BmQ@|uJDkMIHgYXlkz?$d( zh5-9wze1`QN2L}21Q%IBz@Fp9@GOhHY=d>n+3mxJz3b|qKME^+^d|Jc<$peXvU|Bexgt-mr*E(z zdHLs0zyE9b@_8}QF~u>~{4?(FIGo4s9*;Pjw;n$wz2VfUHU||yR+q)C4BeD$uZy`v z=?TFQ!ZrlRjNt|YszmW=@)bAcQ)3IG=C|S9-SKU%B!yzr&%YsKY53azi8mNJFbUfL zyFh z{e}$};*d(^vN8?%{@2S2V7N49pK(7ow-u+b`kN=iFgxjPi^^Gd;0OU=-^AdHJxX|5 zzgscUAB~Kx>{+p5tX;s*<@2fNCEwc5-z2?p??%q5enu}k_@S0R2aBig-1P66J^uu3 zQ@sHr!3i}1kQ{I0`}&}|+n`TJvG05U>}5Z2xcOLwB)-8Ic$+r|*`9Wg1)8cI_Zfk0 zN+MR<>!k=agqRk5Q)a`X+5>ALf6nMZ#~oDhdk~Q;&5aq)P$XU1vLs z{2t4Y-NAo{KEBzKr;MefwXbW7^8I*0?M1b#4wV0o1hu*qx)oF`$q=OTD{<3nfkC44 zjm$9`hPUT7_ul!t${dX$SNUD$zixM2%8jNVn(<+J>i;S@A^8UJB&HfiE=Yp*|)!mR8peGhR_M^2Y4Gdn7ibtQLnuv4Z!%q-$|cO*k3D?9T5uo{K| zh`QQTzQ{B4U;hmp9Z(mXHH4(y{z0Pi;WGy-hwP}piyrzeGGN9~hcRqSvhcvG&WR73 zI@k~b39zHM%Kpj8KQ>g04u`OFfgX>&{6`<|(^AV?Le9}}V!XCi$t%d1hTmJ$_^Wq$PHgv}_=wb?dX`)&Q6tpW(kv$fVvnKktp2)TB+Hmkk{&LB75 zPMP(!a5|~ZUYA*0pAOsFvX#{_0Kahwl0iX87SSckvQ(K z4^NdflAwEmlARk&>5_o-3ZhBUqy_vb*9IKd5{QBKf`I)GdYC6(Mhw*?2NR8<$?69r z`}b3N(7XK+D&()NuTv3JRWZz!D$73H3ok1AeLF*HXJ05+jy`FM_H9 za#}KA=WjFH4s|=_`IIqNX*>YWED1XviY~t}H1*b90og{F4|B)1m^Lfzp-=?O%<#nrVvgDsN+)l1 zoAH=0?3A!D3p`)P(Y14cKHyjddt#2sw7TxL3muxd2W25 zJp<$1zv8NOv>h2{AM@0xhjGGEL)C-phG(K3-ou~!?RMeo31gj~6aUh;I4?!$eWcyR zoy44w5^fL}Fh23!^q$VxS%v*OK=?$#5lKt+-S6WLIeW^4k)935D?9l7X`^)Ry z43WO7c}r&GDJk-YfNlvm@w<;t03L${??vC5I>6R>L77W2PkfT>#L3A6J1@mAjyfDd zqnf+_0?CXxktg-PWYhSofm9MWNJd{dHOSj;0Tne^| zl=8PU{YOgbPRxosbeWOLqbJU#0Q(Bd{%iU5xrPZY3mDsXY`YY=svM_A{gSe?NMxU@ z0)iEA^ERvSulP~R;*HXe1c*A1GGaHXcMLPUd*<}~zpP;C%)a#fEZ}d@Ui$s2*3Dm1 z2*gFSha)ey{y}psOt`?LP$-*wKYdCp#cDf9GfZv@R`7Pj0hC#7RFv( zl6y#0ZS6s+mc{$RS1PEmL&&;no2W}N0Aq8KzT~M3^_6YxWx*kNvr1g>>(LMuF$S#p@Kxy>0V#K>~ z7f99>y?)JG;6`?8f(x9x3V?b&OYoxXhV~^vIwrKv!#F&b4B{7zm zVwHQzyoyI4SrW>Zb!G-aL2;3{j2V(f&gl4jlEB)wl%9r?N(OWap#j}LxFaSFLkE+? zhnO=An1~cRN85-rDD2WR=UDI;J{QqO2{v35e|L&5(#1RplW4&!dV795f1 zz-0dOA~dzI4Nu2e%3V9+M?%w?>A{C zrY%Y9{%3E7kw_%IxpK{#k0Zs2@AN8k;^@T6<8D)(#X1_5 zhJFIPVudi~}{xZF5?%j=eBWBTYo%giPB zFCbU{*U*z&Q>ZtF>_Ky76d@kNG71K|YD;$7WDblxH0|1&>e!IKOu&g)vv$ z+^)n3M}3#*3eccee&J> z?D02YEt^DqzU!a33_rh&xPNwi9>Fv39EuaW6s>-SwO~`}UCTbK)ffMtK5^ZBMd+8M zP6t;8$f?d7cYl;>>-F!Y7Gta)Pe@86;N6(fB1LaYOzO+#^Ts#wQx50TNZ&U=x_?+b zG*pnkdb_GkwQbF#Z0L}>Rek6cUb8K{eYH6xs@6Q5i)A_2qH73co2M`w!_qOZjonZk z`T)TF%K*HYcs{j((3{@17f&XYlSv5)WO7p3R~Ku-HsEN*>DHqkMv{`kvhRKu7W&=Y zubT6ks4RUeU;;>7=DUmp?P-WyUL z6)7!ku6RWB{+gCv+^{Ei?XGsOo2<~Jk@tHVtke%R7f&WtkjM!M)JtOd#Y;8GHr(<1 zvY%OPo8+4}H=0d;>HaY`_V)|^6C$Kg*3YuSy@lBuMO$&5Zi2%{Hlsc0rDs2z?ZdKw zPTW>e6F7l${Q}%$@<_y3Zxhxg8TnfWzkjTF#}Y|F;GUxhCk5$oiUtZ{({Hxlh?_hb z6d7^<%yVuIgiE3}8{%=q4E2jewyo)LhJ{1~-%h@^wX^$$I)jJ=8a9_ivG9u^(gH(z z9B7tC+`Oq$CnSVNBqXTRH*Q9R-@Kvbp@?woaK23biPmh^(5+g{A&se%MBwcA8!=CD z;fnn55#j&$g_K7{NKH-HBhvCEWJ1+ppUILqn^qIEJmEHc__W{(ZR19ttij&Eplz!~ zS^M^T8fJ(ezd(Cf;inR*WBCrLhE+=tq9q~&g7FA2 z^%u|*$q)?fd3LCyo9M|oN-pccmNd3q+6NG*aV8IyLH)&bf^kS1RPR>}p4YEr!jQgA zByRi|hS{)xPBiFmWc8rmqP?uS0=>Xm#47M(jrKnmHLQPlcsOvd+$_I$yQ|iBBNfMS z+t%U7)m2(_*HbGibtc^sPQCt4*1@0-i43jEdmd-u5c2@}BeghOd|fOT2R>4NN76YF zE|vbLiLFQp2(N-ksXnH~YH=xu`|>i9rCq5;GA>`x%-f!cQ>|1%e)~M^_`}Umhgu}_ z@^Qnw9hnIDra^WTsxE->ot3%dhCaJ^6Mq|jlh`^4v`#*r2EkBBS!h!*_meFiAGCF; z^#`wT`Nh1A?vc{iEiutX{}sNz{94iMFn9g&epXS7yUjCC#1|+%vS$4k%*CJsEOY=> z2E{guYS{ira%HIClfV^0lE96UciA*uj>%9#OUEjb{a(6d`+(T%Z7<}T%xQ7|f59Sd z%O!3@U;IBk-n2q>&jJ*!Mf!Tbg?^8h|6LU8Ta%6N8d~VPYBthyn(%&9_~H z;PVklr2n`+T=JuGu&1G z+#b1-=c$yVDn4J2&@w+)pJV_4VSjk7{1XBI5U9?3aSh^e6&Um!?AXMAU!eJV84-%4xL(w7+ZdfXCq=jwRTg(axEn*Z5T5ePs>$FX*2j2cefgSYHHf9!GP(kh7XkF>!5U5DEZznx#A=XXUNQTDE~9 zr>}nk`7l9?$73hC({W=TFPV8yS#e4mo}9eUQ^9(vET7awV7(PiDh2>i>j_=T;^O4) zoGQA!>|R~0Md-YBR^?05JL9Bi^4PJ{!pYJSwtw%c^COSxzZm(gSfBlgy>BGH$4n*t z{l<5sO56QJao+-c8o&~|Jo^HjRuqy`ZnkospUfm*Oy&C(7-Y(4l7ydgL#|IF7p*3W zRx=^<@{8EAr<~gFHTS0?03aR!fZTvJFN|)k#~Mgj#h3cfaWcRD21G<&%;r2)=cfK; zDEoR^RtW&$L5r7o{o$vL-PMg1Ek5=+g?OiP&yAe2#%>L-BEx83>tonGf zFYY$i%JA2TP8Xm*p}}f>K31Ut&MgFb$vqk%)b2!4rw;{TaFyu26eSYQuXx%0#43wE zr`?jsZL|aiVfADisR6?tuWP)+?&h)kN{RtEqONQtGzr9u$sP^;6^{v>-#y|Z52&EiV~Gx@*W<7Y{w z{H(h#GQ9)6ciTYXo9`}Zs>cm@?LBQCZp{u&|3A1q?Y$Hv_tIr`&A0)FyKI&t$dOS~)N@-k zI}ZO@T)cfOeV!tE=i$t&oKjxo5}uB=`NG#H4x?8t{xsX~tmCcr<#9@{g77B8iZ^rV z%8Bui+DhXGoWfq+Fu)+VAxVmc15#pWHrg^wRkq=8vY9eP=FQ_AGAcZ`nN0vpaub zjd>-s-G27l;~@*0H#hf3(G)FXpxs)phmSD!pO(n?x0$X?P81!`QDzQ}ZlJqJx+PJO zsk$g(c1Y=lp7@sjIdJ)CJ|mR0eg(5^ED~~~P`o%1-Z0LkYvY}}SN#_yEBM|Ku3qc+ zzzg|8{$vV|10YDwm6A0nxMX(nvUE)m>yt}}PO-H1-yTKBWu6Pf>EU(xve{emwiBum zCDDt(fY}j(U=%3D3oTD_MX#CDf(RF%`=+@jQ1X&{-?Z$Fs`b-C_G-3pdcABDrDFL3 zv}l6_Im7&hes9+@Q#@GUkf)d%QSZe8rxl zp%n!>x^#q=6@jxB&|G`5;>F#M?v|A;EnY}pypX)W-n^-phht@BSV%~P#84Zgd>+A? zwBJ_zM!fU)ir<6g0i$LdkbGYT%F(i($cCdInjvqYH`M%eq9L-U?54aD2p8ZG5{_I< zOT{I2cF4NRA4+m!4?4wuiU#X~(NAlCIvATHd05qj>#)V*GD+lO5)qK9g*da^|MYX$ zF;_tB_NfFs8yRo?*=?Pc>B?)hcHWyVFP|SNK45z0C)S`Rb@8`-4jZ2|&Y^n}VvWS=rLX{0taA z7h1E@7#AHKZ~TanCm_DdV+{eNO&K(U~Sq^7ov)bN0i-H9sks*;5cKP3aXM5 zuA#KG3j*I022BltR*CUd%)X82dUvDydK66AkDnF!*8A=0Exkr~|8GM;~QD3%Py{eX#m2Izl+DW`mB2E)KpIA1yY)avFm_Gh8rS${T7#xNcaaZPl z+4S{ys?eO=)kGFiS2jJZgqRQnAPZg&001}~YQSQS0O(7Q^!NhtAq}^6UfBeQ+{P@-Jj4!SqbjmR5Cper5y^)!*<@y&4r^cbLyz0Nsnwx!) z|C1$WF)W5w<`7r7`H;t@Gee{V=T!F4tYn<(D{a;j_;nh)=ElhN_(Kc4z8XrYJ9CD? z_~QB|C!N~mD2H>DV{p=Kl04x8u3HuEag!3xmA~t1WBDon!R%bt*Y%4E`j86_#a|z} zp|NY)uv6J>dP1|*m-^w?ecw3Q$8ALpaU~>%hi0VX9a0FMaV>0#!DJH_8TKp)oP7(J zClfY^DJv^DB?175zV=)=5sSNaY3Uz|Xs&j5bo6d5R}uZk(o5HHu@ehDzZOCO2(J`~ zqr4eWB6Uw58%7}W_c9WI0Du5V_PrT`VYaGAEsA1zM~M}dU8~=hEBUbV$3I`ogCNL_ zJG3r9!OzWmzk1iYA+8$)L8_mA-0cj9CG39bA{a5QEkI1jYhtY2=N$R2c4U;vR;|5D zJ|>gL5?>diWLM+za`y(tzK>t(yl9vENK0G0Z3edv*iiFSOx zPnEu1;M*Lb{tbzAb!{{Y!C_*Z2DK_lONZvTk&j9k#hFg z@^wvH$KqsATnvZ8NPH-FaXQ+2`KD0pxk>$ccdK3&#_RKHae`s~JQKusU$!U?c{h7F zCd;$43ANHe-B6)tZ*Y{7wMdlY-iPzyju)B#p^u_ljJlFOR|SRx7I6PdSDuN05X402 zCBk7$kaO9PGvzl*9_`jq?o#rkPat);T;3A9UA5)iwLR-b!AkKhy4Tb3&;|D*FS3@j51Yq^%4?A?SwIo&>|VMk%RW;56Fe5};@{Xp{X5 zS(h${f%F9u0}x3C8wrACthGl;$TM4GKe>%L9K!954cY=g&elujrfItsM09jiI=PKW z*-$-NEKe>_`o5$=%&CoZ7uisal%h_4omt$0+~Ajx#kL0$LMe#`Boy5Sun`GML&VF) zZv;AM#L^_F1HvPsL`4#uC+Q>zZa}jqAh7uOAU}vT2BK2RYPYob2jWSFOsFE0iJVzB zx0aSE$iPKt7Ltk#hDOX{xbg!kY$|;C6!Zghi9NPI%>bW)PhB4)0%nBZpg%%_3WN(5 zJO>Y`V39@01vek00C#R&*a_jth`}5L2i*`73`Mx0?m2kC!DJCcaAj)>2xkYVp_~R5 z61lnc#PCbESq2Zh)fxatw_xl%(Q|};OC0gpN7#(EpPabxzZDL;=c2PP5}VH z0EQ5NKsX2w5g;N&g2)gBqCzx?4ly7nh>0muaT=Y$WU)B}$>k|2tEj4}YiMfm1wxTn zqC=-H-Fo!u({I3_A;U(D8Z&Ofq-07eY8qN8bW){BmmyP@Y#)dTODwg_|14)^!{YD+B8g0)(&!8( zi_PKk_yVCwERo9O3Z+V|(dzUDqseTs+8i96oLvA{H+K(DFK>IIzs1k~i|6P2gA2i% z!~qatLKzoQY0EZw>H$QUP{xH++Okcaqi6sC0000$k|arzBq>RfBuSEl9{>OV002Oe vBuSDaDM^wfNs?q{W@ct)W@hH@?(XjH?(Qz)%*@Qp%*@QpkKX{?00000lSDas literal 23336 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 D=LH(w diff --git a/src/assets/icons/forumTopic/blue.svg b/src/assets/icons/forumTopic/blue.svg new file mode 100644 index 000000000..6fe65a28d --- /dev/null +++ b/src/assets/icons/forumTopic/blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/forumTopic/green.svg b/src/assets/icons/forumTopic/green.svg new file mode 100644 index 000000000..d81ac25ad --- /dev/null +++ b/src/assets/icons/forumTopic/green.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/forumTopic/grey.svg b/src/assets/icons/forumTopic/grey.svg new file mode 100644 index 000000000..fe1e6bb33 --- /dev/null +++ b/src/assets/icons/forumTopic/grey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/forumTopic/red.svg b/src/assets/icons/forumTopic/red.svg new file mode 100644 index 000000000..f6c2cff5a --- /dev/null +++ b/src/assets/icons/forumTopic/red.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/forumTopic/rose.svg b/src/assets/icons/forumTopic/rose.svg new file mode 100644 index 000000000..6a8f78aa3 --- /dev/null +++ b/src/assets/icons/forumTopic/rose.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/forumTopic/violet.svg b/src/assets/icons/forumTopic/violet.svg new file mode 100644 index 000000000..77df6d03f --- /dev/null +++ b/src/assets/icons/forumTopic/violet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/forumTopic/yellow.svg b/src/assets/icons/forumTopic/yellow.svg new file mode 100644 index 000000000..31484a142 --- /dev/null +++ b/src/assets/icons/forumTopic/yellow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/calls/group/GroupCallTopPane.tsx b/src/components/calls/group/GroupCallTopPane.tsx index c41610e82..480f8068f 100644 --- a/src/components/calls/group/GroupCallTopPane.tsx +++ b/src/components/calls/group/GroupCallTopPane.tsx @@ -20,6 +20,7 @@ import './GroupCallTopPane.scss'; type OwnProps = { chatId: string; hasPinnedOffset: boolean; + className?: string; }; type StateProps = { @@ -33,6 +34,7 @@ type StateProps = { const GroupCallTopPane: FC = ({ chatId, isActive, + className, groupCall, hasPinnedOffset, usersById, @@ -97,6 +99,7 @@ const GroupCallTopPane: FC = ({ 'GroupCallTopPane', hasPinnedOffset && 'has-pinned-offset', !isActive && 'is-hidden', + className, )} onClick={handleJoinGroupCall} > diff --git a/src/components/common/AnimatedCounter.module.scss b/src/components/common/AnimatedCounter.module.scss index 06e9a1419..64104d27d 100644 --- a/src/components/common/AnimatedCounter.module.scss +++ b/src/components/common/AnimatedCounter.module.scss @@ -6,6 +6,10 @@ $animation-time: 0.15s; .root { display: inline-flex; white-space: pre; + + &[dir="rtl"] { + flex-direction: row-reverse; + } } .character-container { diff --git a/src/components/common/AnimatedCounter.tsx b/src/components/common/AnimatedCounter.tsx index e4bfedc7d..e4e287d68 100644 --- a/src/components/common/AnimatedCounter.tsx +++ b/src/components/common/AnimatedCounter.tsx @@ -6,6 +6,7 @@ import { ANIMATION_LEVEL_MAX } from '../../config'; import usePrevious from '../../hooks/usePrevious'; import useForceUpdate from '../../hooks/useForceUpdate'; import useTimeout from '../../hooks/useTimeout'; +import useLang from '../../hooks/useLang'; import styles from './AnimatedCounter.module.scss'; @@ -18,6 +19,8 @@ const ANIMATION_TIME = 150; const AnimatedCounter: FC = ({ text, }) => { + const lang = useLang(); + const prevText = usePrevious(text); const forceUpdate = useForceUpdate(); @@ -54,7 +57,7 @@ const AnimatedCounter: FC = ({ }, shouldAnimate && isAnimatingRef.current ? ANIMATION_TIME : undefined); return ( - + {textElement} ); diff --git a/src/components/common/Avatar.scss b/src/components/common/Avatar.scss index 27064082a..b15a0a43e 100644 --- a/src/components/common/Avatar.scss +++ b/src/components/common/Avatar.scss @@ -1,11 +1,12 @@ .Avatar { --color-user: var(--color-primary); + --radius: 50%; flex: none; align-items: center; justify-content: center; width: 3.375rem; height: 3.375rem; - border-radius: 50%; + border-radius: var(--radius); background: linear-gradient(var(--color-white) -125%, var(--color-user)); color: white; font-weight: bold; @@ -15,7 +16,7 @@ position: relative; &__media { - border-radius: 50%; + border-radius: var(--radius); width: 100%; height: 100%; } @@ -26,11 +27,7 @@ } &__icon { - font-size: 2.5rem; - - &.icon-reply-filled { - transform: scale(0.7); - } + font-size: 1.25rem; } &.size-micro { @@ -81,7 +78,7 @@ font-size: 1.3125rem; i { - font-size: 3.5rem; + font-size: 1.625rem; } .emoji { @@ -131,4 +128,8 @@ top: 0; z-index: 0; } + + &.forum { + --radius: var(--border-radius-forum-avatar); + } } diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 04b62de0d..6b07c2d44 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -83,6 +83,7 @@ const Avatar: FC = ({ const isIntersecting = useIsIntersecting(ref, observeIntersection); const isDeleted = user && isDeletedUser(user); const isReplies = user && isChatWithRepliesBot(user.id); + const isForum = chat?.isForum; let imageHash: string | undefined; let videoHash: string | undefined; @@ -184,6 +185,7 @@ const Avatar: FC = ({ isSavedMessages && 'saved-messages', isDeleted && 'deleted-account', isReplies && 'replies-bot-account', + isForum && 'forum', isOnline && 'online', onClick && 'interactive', (!isSavedMessages && !imgBlobUrl) && 'no-photo', diff --git a/src/components/common/ChatExtra.tsx b/src/components/common/ChatExtra.tsx index 2175aeb7a..fa09e7afb 100644 --- a/src/components/common/ChatExtra.tsx +++ b/src/components/common/ChatExtra.tsx @@ -11,10 +11,17 @@ import type { import { TME_LINK_PREFIX } from '../../config'; import { - selectChat, selectNotifyExceptions, selectNotifySettings, selectUser, + selectChat, selectCurrentMessageList, selectNotifyExceptions, selectNotifySettings, selectUser, } from '../../global/selectors'; import { - getChatDescription, getChatLink, getHasAdminRight, isChatChannel, isUserId, isUserRightBanned, selectIsChatMuted, + getChatDescription, + getChatLink, + getTopicLink, + getHasAdminRight, + isChatChannel, + isUserId, + isUserRightBanned, + selectIsChatMuted, } from '../../global/helpers'; import renderText from './helpers/renderText'; import { copyTextToClipboard } from '../../util/clipboard'; @@ -38,6 +45,8 @@ type StateProps = canInviteUsers?: boolean; isMuted?: boolean; phoneCodeList: ApiCountryCode[]; + isForum?: boolean; + topicId?: number; } & Pick; @@ -51,11 +60,14 @@ const ChatExtra: FC = ({ canInviteUsers, isMuted, phoneCodeList, + isForum, + topicId, }) => { const { loadFullUser, showNotification, updateChatMutedState, + updateTopicMutedState, } = getActions(); const { @@ -84,19 +96,35 @@ const ChatExtra: FC = ({ return result?.length ? result : undefined; }, [chatUsernames, user]); - const link = useMemo(() => (chat ? getChatLink(chat) : undefined), [chat]); + const link = useMemo(() => { + if (!chat) { + return undefined; + } + + return isForum + ? getTopicLink(chat.id, activeChatUsernames?.[0].username, topicId) + : getChatLink(chat); + }, [chat, isForum, activeChatUsernames, topicId]); const handleNotificationChange = useCallback(() => { setAreNotificationsEnabled((current) => { const newAreNotificationsEnabled = !current; runDebounced(() => { - updateChatMutedState({ chatId, isMuted: !newAreNotificationsEnabled }); + if (topicId) { + updateTopicMutedState({ + chatId: chatId!, + topicId, + isMuted: !newAreNotificationsEnabled, + }); + } else { + updateChatMutedState({ chatId, isMuted: !newAreNotificationsEnabled }); + } }); return newAreNotificationsEnabled; }); - }, [chatId, updateChatMutedState]); + }, [chatId, topicId, updateChatMutedState, updateTopicMutedState]); if (!chat || chat.isRestricted || (isSelf && !forceShowSelf)) { return undefined; @@ -112,6 +140,7 @@ const ChatExtra: FC = ({ function renderUsernames(usernameList: ApiUsername[], isChat?: boolean) { const [mainUsername, ...otherUsernames] = usernameList; + const usernameLinks = otherUsernames.length ? (lang('UsernameAlso', '%USERNAMES%') as string) .split('%') @@ -139,6 +168,10 @@ const ChatExtra: FC = ({ }) : undefined; + const publicLink = isForum + ? getTopicLink('', mainUsername.username, topicId) + : `@${mainUsername.username}`; + return ( = ({ narrow ripple // eslint-disable-next-line react/jsx-no-bind - onClick={() => copy(`@${mainUsername.username}`, lang(isChat ? 'Link' : 'Username'))} + onClick={() => copy(publicLink, lang(isChat ? 'Link' : 'Username'))} > - {renderText(mainUsername.username)} + {publicLink} {usernameLinks && {usernameLinks}} {lang(isChat ? 'Link' : 'Username')} @@ -215,7 +248,10 @@ export default memo(withGlobal( const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined; const user = isUserId(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined; + const isForum = chat?.isForum; const isMuted = chat && selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)); + const { threadId } = selectCurrentMessageList(global) || {}; + const topicId = isForum ? threadId : undefined; const canInviteUsers = chat && !user && ( (!isChatChannel(chat) && !isUserRightBanned(chat, 'inviteUsers')) @@ -223,7 +259,14 @@ export default memo(withGlobal( ); return { - lastSyncTime, phoneCodeList, chat, user, canInviteUsers, isMuted, + lastSyncTime, + phoneCodeList, + chat, + user, + canInviteUsers, + isMuted, + isForum, + topicId, }; }, )(ChatExtra)); diff --git a/src/components/common/ChatForumLastMessage.module.scss b/src/components/common/ChatForumLastMessage.module.scss new file mode 100644 index 000000000..3133f0b4d --- /dev/null +++ b/src/components/common/ChatForumLastMessage.module.scss @@ -0,0 +1,158 @@ +$radius: 0.5rem; + +.root { + --first-column-background-color: var(--color-item-active); + + display: flex; + min-width: 0; + overflow: hidden; + margin-inline-end: 0.25rem; + + flex-direction: column; + align-items: flex-start; + z-index: 3; + + transition: 0.25s ease-out background-color; + + pointer-events: none; + + &:hover { + --first-column-background-color: var(--color-borders); + } +} + +.title-row { + display: flex; + max-width: 100%; +} + +.loading { + color: var(--color-text-secondary); +} + +.other-column, .main-column { + display: flex; + align-items: center; + font-size: 0.9375rem; + color: var(--color-text-secondary); +} + +.unread { + font-weight: 500; + color: var(--color-text); + + &.main-column, &.last-message { + padding: 0 0.25rem; + } + + &.main-column, &.last-message, .after-wrapper { + transition: background-color 0.15s ease-in-out; + background: var(--first-column-background-color); + } +} + +.other-column { + margin-left: 0.25rem; + margin-right: 0.25rem; + display: inline; +} + +.main-column { + border-start-start-radius: $radius; + border-start-end-radius: $radius; + + max-width: 100%; + + position: relative; + + pointer-events: initial; + + border-end-end-radius: $radius; + + .after-wrapper { + width: $radius; + height: $radius; + bottom: 0; + position: absolute; + inset-inline-end: -$radius; + } + + .after { + border-end-start-radius: $radius; + background: var(--background-color); + width: 100%; + height: 100%; + } +} + +.title { + margin-left: 0.25rem; + font-size: 0.9375rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.other-column-title { + font-size: 0.9375rem; + margin-left: 0.25rem; +} + +.other-columns { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.ellipsis { + margin-left: auto; +} + +.last-message { + border-end-start-radius: $radius; + border-end-end-radius: $radius; + + max-width: 100%; + + pointer-events: initial; + + position: relative; + + .after-wrapper { + width: $radius; + height: $radius; + top: 0; + position: absolute; + inset-inline-end: -$radius; + } + + .after { + border-start-start-radius: $radius; + background: var(--background-color); + width: 100%; + height: 100%; + } +} + +.reverse-corner { + .main-column { + border-end-end-radius: 0; + } + + .last-message { + border-start-end-radius: $radius; + } +} + +.overwritten-width { + .last-message, .main-column { + min-width: var(--overwritten-width); + } + + .last-message { + border-start-end-radius: 0; + } + + .main-column { + border-end-end-radius: 0; + } +} diff --git a/src/components/common/ChatForumLastMessage.tsx b/src/components/common/ChatForumLastMessage.tsx new file mode 100644 index 000000000..23605d799 --- /dev/null +++ b/src/components/common/ChatForumLastMessage.tsx @@ -0,0 +1,147 @@ +import React, { + memo, + useLayoutEffect, + useMemo, + useRef, + useState, +} from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { FC } from '../../lib/teact/teact'; +import type { ApiChat } from '../../api/types'; + +import { REM } from './helpers/mediaDimensions'; +import buildClassName from '../../util/buildClassName'; +import { getOrderedTopics } from '../../global/helpers'; +import renderText from './helpers/renderText'; +import useLang from '../../hooks/useLang'; + +import TopicIcon from './TopicIcon'; + +import styles from './ChatForumLastMessage.module.scss'; + +type OwnProps = { + chat: ApiChat; + renderLastMessage: () => React.ReactNode; + observeIntersection?: ObserveFn; +}; + +const NO_CORNER_THRESHOLD = Number(REM); + +const ChatForumLastMessage: FC = ({ + chat, + renderLastMessage, + observeIntersection, +}) => { + const { openChat } = getActions(); + + // eslint-disable-next-line no-null/no-null + const lastMessageRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const mainColumnRef = useRef(null); + + const lang = useLang(); + + const lastMessage = renderLastMessage(); + + const [lastActiveTopic, ...otherTopics] = useMemo(() => { + return chat.topics ? getOrderedTopics(Object.values(chat.topics), undefined, true) : []; + }, [chat.topics]); + + const [isReversedCorner, setIsReversedCorner] = useState(false); + const [overwrittenWidth, setOverwrittenWidth] = useState(undefined); + + function handleOpenTopic(e: React.MouseEvent) { + if (lastActiveTopic.unreadCount === 0) return; + e.stopPropagation(); + openChat({ id: chat.id, threadId: lastActiveTopic.id, shouldReplaceHistory: true }); + } + + useLayoutEffect(() => { + const lastMessageElement = lastMessageRef.current; + const mainColumnElement = mainColumnRef.current; + if (!lastMessageElement || !mainColumnElement) return; + + const lastMessageWidth = lastMessageElement.offsetWidth; + const mainColumnWidth = mainColumnElement.offsetWidth; + + if (Math.abs(lastMessageWidth - mainColumnWidth) < NO_CORNER_THRESHOLD) { + setOverwrittenWidth(Math.max(lastMessageWidth, mainColumnWidth)); + } else { + setOverwrittenWidth(undefined); + } + setIsReversedCorner(lastMessageWidth > mainColumnWidth); + }, [lastActiveTopic, lastMessage]); + + return ( +
+ {lastActiveTopic && ( + +
+ +
{renderText(lastActiveTopic.title)}
+ {!overwrittenWidth && isReversedCorner && ( +
+
+
+ )} +
+ +
+ {otherTopics.map((topic) => ( +
+ + {renderText(topic.title)} +
+ ))} +
+ +
+ + )} + {!lastActiveTopic &&
{lang('Loading')}
} + + {lastMessage} + {!overwrittenWidth && !isReversedCorner && ( +
+
+
+ )} + + +
+ ); +}; + +export default memo(ChatForumLastMessage); diff --git a/src/components/common/ChatLink.tsx b/src/components/common/ChatLink.tsx index c7dd59f40..cf85a67bb 100644 --- a/src/components/common/ChatLink.tsx +++ b/src/components/common/ChatLink.tsx @@ -1,7 +1,8 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { useCallback } from '../../lib/teact/teact'; +import React, { memo, useCallback } from '../../lib/teact/teact'; import { getActions } from '../../global'; +import type { FC } from '../../lib/teact/teact'; + import buildClassName from '../../util/buildClassName'; import Link from '../ui/Link'; @@ -32,4 +33,4 @@ const ChatLink: FC = ({ ); }; -export default ChatLink; +export default memo(ChatLink); diff --git a/src/components/common/ChatOrUserPicker.scss b/src/components/common/ChatOrUserPicker.scss index 73e2e833a..40786fe61 100644 --- a/src/components/common/ChatOrUserPicker.scss +++ b/src/components/common/ChatOrUserPicker.scss @@ -42,6 +42,11 @@ display: flex; flex-direction: column; + > .Transition { + height: 100%; + overflow: hidden; + } + .picker-list { height: 100%; overflow-x: hidden; @@ -85,4 +90,38 @@ } } } + + .topic-icon { + --custom-emoji-size: 2.75rem; + + margin-inline-end: 0.25rem !important; + width: 2.75rem; + height: 2.75rem; + font-size: 2.75rem !important; + } + + .topic-icon-letter { + font-size: 1.5rem; + } + + .topic-item { + .ListItem-button { + display: flex; + align-items: center; + + font-size: 1rem; + line-height: 1.6875rem; + font-weight: 500; + } + + .fullName { + overflow: hidden; + text-overflow: ellipsis; + } + + .emoji-small { + width: 1rem; + height: 1rem; + } + } } diff --git a/src/components/common/ChatOrUserPicker.tsx b/src/components/common/ChatOrUserPicker.tsx index de636277e..9c229001e 100644 --- a/src/components/common/ChatOrUserPicker.tsx +++ b/src/components/common/ChatOrUserPicker.tsx @@ -1,12 +1,19 @@ import type { FC } from '../../lib/teact/teact'; -import React, { memo, useRef, useCallback } from '../../lib/teact/teact'; +import React, { + memo, useRef, useCallback, useState, useMemo, +} from '../../lib/teact/teact'; +import { getActions } from '../../global'; +import type { ApiChat, ApiTopic } from '../../api/types'; + +import { REM } from './helpers/mediaDimensions'; import { CHAT_HEIGHT_PX } from '../../config'; +import renderText from './helpers/renderText'; +import { getCanPostInChat, isUserId } from '../../global/helpers'; import useInfiniteScroll from '../../hooks/useInfiniteScroll'; import useLang from '../../hooks/useLang'; import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; import useInputFocusOnOpen from '../../hooks/useInputFocusOnOpen'; -import { isUserId } from '../../global/helpers'; import Loading from '../ui/Loading'; import Modal from '../ui/Modal'; @@ -16,26 +23,34 @@ import InfiniteScroll from '../ui/InfiniteScroll'; import ListItem from '../ui/ListItem'; import GroupChatInfo from './GroupChatInfo'; import PrivateChatInfo from './PrivateChatInfo'; +import Transition from '../ui/Transition'; +import TopicIcon from './TopicIcon'; import './ChatOrUserPicker.scss'; export type OwnProps = { currentUserId?: string; chatOrUserIds: string[]; + chatsById?: Record; isOpen: boolean; searchPlaceholder: string; search: string; loadMore?: NoneToVoidFunction; onSearchChange: (search: string) => void; - onSelectChatOrUser: (chatOrUserId: string) => void; + onSelectChatOrUser: (chatOrUserId: string, threadId?: number) => void; onClose: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction; }; +const CHAT_LIST_SLIDE = 0; +const TOPIC_LIST_SLIDE = 1; +const TOPIC_ICON_SIZE = 2.75 * REM; + const ChatOrUserPicker: FC = ({ isOpen, currentUserId, chatOrUserIds, + chatsById, search, searchPlaceholder, loadMore, @@ -44,89 +59,218 @@ const ChatOrUserPicker: FC = ({ onClose, onCloseAnimationEnd, }) => { + const { loadTopics } = getActions(); + const lang = useLang(); - const [viewportIds, getMore] = useInfiniteScroll(loadMore, chatOrUserIds, Boolean(search)); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const topicContainerRef = useRef(null); // eslint-disable-next-line no-null/no-null const searchRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const topicSearchRef = useRef(null); + const [viewportIds, getMore] = useInfiniteScroll(loadMore, chatOrUserIds, Boolean(search)); + const [forumId, setForumId] = useState(undefined); + const [topicSearch, setTopicSearch] = useState(''); + const activeKey = forumId ? TOPIC_LIST_SLIDE : CHAT_LIST_SLIDE; + const viewportOffset = chatOrUserIds!.indexOf(viewportIds![0]); const resetSearch = useCallback(() => { onSearchChange(''); }, [onSearchChange]); - useInputFocusOnOpen(searchRef, isOpen, resetSearch); + useInputFocusOnOpen(searchRef, isOpen && activeKey === CHAT_LIST_SLIDE, resetSearch); + useInputFocusOnOpen(topicSearchRef, isOpen && activeKey === TOPIC_LIST_SLIDE); + + const [topicIds, topics] = useMemo(() => { + const topicsResult = forumId ? chatsById?.[forumId].topics : undefined; + if (!topicsResult) { + return [undefined, undefined]; + } + + const searchTitle = topicSearch.toLowerCase(); + + const result = topicsResult + ? Object.values(topicsResult).reduce((acc, topic) => { + if ( + getCanPostInChat(chatsById![forumId!], topic.id) + && (!searchTitle || topic.title.toLowerCase().includes(searchTitle)) + ) { + acc[topic.id] = topic; + } + + return acc; + }, {} as Record) + : topicsResult; + + return [Object.keys(result).map(Number), result]; + }, [chatsById, forumId, topicSearch]); + + const handleHeaderBackClick = useCallback(() => { + setForumId(undefined); + setTopicSearch(''); + }, []); - // eslint-disable-next-line no-null/no-null - const containerRef = useRef(null); const handleSearchChange = useCallback((e: React.ChangeEvent) => { onSearchChange(e.currentTarget.value); }, [onSearchChange]); + + const handleTopicSearchChange = useCallback((e: React.ChangeEvent) => { + setTopicSearch(e.currentTarget.value); + }, []); + const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => { if (viewportIds && viewportIds.length > 0) { - onSelectChatOrUser(viewportIds[index === -1 ? 0 : index]); + const chatId = viewportIds[index === -1 ? 0 : index]; + const chat = chatsById?.[chatId]; + if (chat?.isForum) { + if (!chat.topics) loadTopics({ chatId }); + setForumId(chatId); + } else { + onSelectChatOrUser(chatId); + } } }, '.ListItem-button', true); - const modalHeader = ( -
- - -
- ); + const handleTopicKeyDown = useKeyboardListNavigation(topicContainerRef, isOpen, (index) => { + if (topicIds?.length) { + onSelectChatOrUser(forumId!, topicIds[index === -1 ? 0 : index]); + } + }, '.ListItem-button', true); - const viewportOffset = chatOrUserIds!.indexOf(viewportIds![0]); + const handleClick = useCallback((e: React.MouseEvent, chatId: string) => { + const chat = chatsById?.[chatId]; + if (chat?.isForum) { + if (!chat.topics) loadTopics({ chatId }); + setForumId(chatId); + resetSearch(); + } else { + onSelectChatOrUser(chatId); + } + }, [chatsById, loadTopics, onSelectChatOrUser, resetSearch]); + + const handleTopicClick = useCallback((e: React.MouseEvent, topicId: number) => { + onSelectChatOrUser(forumId!, topicId); + }, [forumId, onSelectChatOrUser]); + + function renderTopicList() { + return ( + <> +
+ + +
+ + {topicIds + ? topicIds.map((topicId, i) => ( + + +
{renderText(topics[topicId].title)}
+
+ )) + : } +
+ + ); + } + + function renderChatList() { + return ( + <> +
+ + +
+ {viewportIds?.length ? ( + + {viewportIds.map((id, i) => ( + + {isUserId(id) ? ( + + ) : ( + + )} + + ))} + + ) : viewportIds && !viewportIds.length ? ( +

{lang('lng_blocked_list_not_found')}

+ ) : ( + + )} + + ); + } return ( - {viewportIds?.length ? ( - - {viewportIds.map((id, i) => ( - onSelectChatOrUser(id)} - > - {isUserId(id) ? ( - - ) : ( - - )} - - ))} - - ) : viewportIds && !viewportIds.length ? ( -

{lang('lng_blocked_list_not_found')}

- ) : ( - - )} + + {() => { + return activeKey === TOPIC_LIST_SLIDE ? renderTopicList() : renderChatList(); + }} +
); }; diff --git a/src/components/common/CustomEmoji.module.scss b/src/components/common/CustomEmoji.module.scss index b78365d5e..2b0c718d1 100644 --- a/src/components/common/CustomEmoji.module.scss +++ b/src/components/common/CustomEmoji.module.scss @@ -4,6 +4,7 @@ width: var(--custom-emoji-size); height: var(--custom-emoji-size); position: relative; + flex: 0 0 var(--custom-emoji-size); &.with-grid-fix .media, &.with-grid-fix .thumb { width: calc(100% + 1px) !important; @@ -13,6 +14,10 @@ &:global(.custom-color) { --emoji-status-color: var(--color-primary); } + + canvas { + display: block; + } } .thumb { diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index cc93e8263..893d07a62 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -1,7 +1,9 @@ import type { FC } from '../../lib/teact/teact'; import React, { useRef } from '../../lib/teact/teact'; -import type { ApiUser, ApiMessage, ApiChat } from '../../api/types'; +import type { + ApiUser, ApiMessage, ApiChat, +} from '../../api/types'; import { getMessageMediaHash, diff --git a/src/components/common/GroupChatInfo.tsx b/src/components/common/GroupChatInfo.tsx index b8d4294d8..82ece7b1d 100644 --- a/src/components/common/GroupChatInfo.tsx +++ b/src/components/common/GroupChatInfo.tsx @@ -5,27 +5,39 @@ import React, { } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { ApiChat, ApiTypingStatus } from '../../api/types'; +import type { + ApiChat, ApiTopic, ApiThreadInfo, ApiTypingStatus, +} from '../../api/types'; import type { GlobalState } from '../../global/types'; import type { AnimationLevel } from '../../types'; +import type { LangFn } from '../../hooks/useLang'; import { MediaViewerOrigin } from '../../types'; +import { REM } from './helpers/mediaDimensions'; import { getChatTypeString, getMainUsername, isChatSuperGroup, } from '../../global/helpers'; -import { selectChat, selectChatMessages, selectChatOnlineCount } from '../../global/selectors'; -import type { LangFn } from '../../hooks/useLang'; +import { + selectChat, selectChatMessages, selectChatOnlineCount, selectThreadInfo, +} from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; +import renderText from './helpers/renderText'; import useLang from '../../hooks/useLang'; import Avatar from './Avatar'; import TypingStatus from './TypingStatus'; import DotAnimation from './DotAnimation'; import FullNameTitle from './FullNameTitle'; +import TopicIcon from './TopicIcon'; + +const TOPIC_ICON_SIZE = 2.5 * REM; type OwnProps = { chatId: string; + threadId?: number; + className?: string; typingStatus?: ApiTypingStatus; avatarSize?: 'small' | 'medium' | 'large' | 'jumbo'; status?: string; @@ -37,11 +49,15 @@ type OwnProps = { withChatType?: boolean; withVideoAvatar?: boolean; noRtl?: boolean; + noAvatar?: boolean; + onClick?: VoidFunction; }; type StateProps = { chat?: ApiChat; + threadInfo?: ApiThreadInfo; + topic?: ApiTopic; onlineCount?: number; areMessagesLoaded: boolean; animationLevel: AnimationLevel; @@ -50,7 +66,9 @@ type StateProps = const GroupChatInfo: FC = ({ typingStatus, + className, avatarSize = 'medium', + noAvatar, status, withDots, withMediaViewer, @@ -59,12 +77,15 @@ const GroupChatInfo: FC = ({ withUpdatingStatus, withChatType, withVideoAvatar, + threadInfo, noRtl, chat, onlineCount, areMessagesLoaded, animationLevel, lastSyncTime, + topic, + onClick, }) => { const { loadFullChat, @@ -73,6 +94,7 @@ const GroupChatInfo: FC = ({ } = getActions(); const isSuperGroup = chat && isChatSuperGroup(chat); + const isTopic = Boolean(chat?.isForum && threadInfo && topic); const { id: chatId, isMin, isRestricted } = chat || {}; useEffect(() => { @@ -123,6 +145,14 @@ const GroupChatInfo: FC = ({ return ; } + if (isTopic) { + return ( + + {threadInfo?.messagesCount ? lang('messages', threadInfo.messagesCount, 'i') : renderText(chat.title)} + + ); + } + if (withChatType) { return ( {lang(getChatTypeString(chat))} @@ -142,17 +172,30 @@ const GroupChatInfo: FC = ({ } return ( -
- +
+ {!noAvatar && !isTopic && ( + + )} + {isTopic && ( + + )}
- + {topic + ?

{renderText(topic.title)}

+ : } {renderStatusOrTyping()}
@@ -177,16 +220,20 @@ function getGroupStatus(lang: LangFn, chat: ApiChat) { } export default memo(withGlobal( - (global, { chatId }): StateProps => { + (global, { chatId, threadId }): StateProps => { const { lastSyncTime } = global; 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 ? chat?.topics?.[threadId] : undefined; return { lastSyncTime, chat, + threadInfo, onlineCount, + topic, areMessagesLoaded, animationLevel: global.settings.byKey.animationLevel, }; diff --git a/src/components/common/PickerSelectedItem.scss b/src/components/common/PickerSelectedItem.scss index 8ce8e2631..91867087e 100644 --- a/src/components/common/PickerSelectedItem.scss +++ b/src/components/common/PickerSelectedItem.scss @@ -69,7 +69,7 @@ transition: opacity 0.15s ease; .Avatar__icon, i { - font-size: 2rem; + font-size: 1rem; } } @@ -120,6 +120,16 @@ transition: opacity 0.15s ease; } + + &.forum-avatar { + border-start-start-radius: 0.625rem; + border-end-start-radius: 0.625rem; + + .item-remove { + border-radius: 0.625rem; + } + } + &[dir="rtl"] { padding-left: 1rem; padding-right: 0; diff --git a/src/components/common/PickerSelectedItem.tsx b/src/components/common/PickerSelectedItem.tsx index 1ec4cb5e0..44adcd555 100644 --- a/src/components/common/PickerSelectedItem.tsx +++ b/src/components/common/PickerSelectedItem.tsx @@ -74,6 +74,7 @@ const PickerSelectedItem: FC = ({ const fullClassName = buildClassName( 'PickerSelectedItem', className, + chat?.isForum && 'forum-avatar', isMinimized && 'minimized', canClose && 'closeable', ); diff --git a/src/components/common/ProfileInfo.module.scss b/src/components/common/ProfileInfo.module.scss new file mode 100644 index 000000000..c3a31d3e6 --- /dev/null +++ b/src/components/common/ProfileInfo.module.scss @@ -0,0 +1,179 @@ +.self { + margin: 0 -0.5rem 0.75rem; + overflow: hidden; + + &:global(.ghost) { + margin: 0; + } + + .info { + padding-bottom: 0.75rem; + } + + .status { + line-height: 1rem; + } +} + +.photoWrapper { + width: 100%; + position: absolute; + left: 0; + top: 0; + bottom: 0; + + > :global(.Transition) { + width: 100%; + height: 100%; + } +} + +.photoDashes { + position: absolute; + width: 100%; + height: 0.125rem; + padding: 0 0.375rem; + z-index: 1; + + display: flex; + top: 0.5rem; + left: 0; +} + +.photoDash { + flex: 1 1 auto; + background-color: var(--color-white); + opacity: 0.25; + border-radius: 0.125rem; + margin: 0 0.125rem; + transition: opacity 300ms ease; + + &_current { + opacity: 0.75; + } +} + +.navigation { + position: absolute; + top: 0; + bottom: 0; + width: 25%; + border: none; + padding: 0; + margin: 0; + appearance: none; + background: transparent no-repeat; + background-size: 1.25rem; + opacity: 0.25; + transition: opacity 0.15s; + outline: none; + cursor: pointer; + z-index: 1; + + &:global(:hover), + :global(.is-touch-env) & { + opacity: 1; + } + + &_prev { + left: 0; + background-image: url("../../assets/media_navigation_previous.svg"); + background-position: 1.25rem 50%; + + &[dir="rtl"] { + left: auto; + right: 0; + transform: scaleX(-1); + } + } + + &_next { + right: 0; + background-image: url("../../assets/media_navigation_next.svg"); + background-position: calc(100% - 1.25rem) 50%; + + &[dir="rtl"] { + left: 0; + right: auto; + transform: scaleX(-1); + } + } +} + +.info { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + min-height: 100px; + padding: 0 1.5rem 0.5rem; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%); + color: var(--color-white); + display: flex; + flex-direction: column; + justify-content: flex-end; + pointer-events: none; + + &:dir(rtl) { + .status { + text-align: right; + unicode-bidi: plaintext; + } + } + + &[dir="rtl"] { + .status { + text-align: right; + unicode-bidi: plaintext; + } + } +} + +.status { + font-size: 0.875rem; + opacity: 0.5; +} + +.topicContainer { + --custom-emoji-size: 7.5rem; + + padding: 1rem 1rem 0.75rem; +} + +.topicTitle { + font-size: 1.25rem; + line-height: 1.5rem; + text-align: center; + margin: 0.5rem 0 0; +} + +.topicIcon { + margin: auto; + width: 7.5rem !important; + height: 7.5rem !important; + display: flex !important; + + &:global(.general-forum-icon) { + font-size: 7.5rem; + color: var(--color-text-secondary); + } +} + +.topicIconTitle { + font-size: 3rem !important; + + font-weight: 400; + + :global(.emoji-small) { + width: 3rem; + height: 3rem; + } +} + +.topicMessagesCounter { + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-text-secondary); + margin: 0; + text-align: center; +} diff --git a/src/components/common/ProfileInfo.scss b/src/components/common/ProfileInfo.scss index 7fc332197..20fa0a296 100644 --- a/src/components/common/ProfileInfo.scss +++ b/src/components/common/ProfileInfo.scss @@ -1,3 +1,4 @@ +// This class is used in `ghostAnimation`, so we need to keep it global .ProfileInfo { aspect-ratio: 1 / 1; position: relative; @@ -16,176 +17,37 @@ } } - .photo-wrapper { - width: 100%; - position: absolute; - left: 0; - top: 0; - bottom: 0; - - > .Transition { - width: 100%; - height: 100%; - } + .fullName { + font-weight: 500; + font-size: 1.25rem; + line-height: 1.375rem; + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 0; } - .photo-dashes { - position: absolute; - width: 100%; - height: 0.125rem; - padding: 0 0.375rem; - z-index: 1; - - display: flex; - top: 0.5rem; - left: 0; + .VerifiedIcon, + .PremiumIcon { + z-index: 2; + --color-fill: var(--color-white); + --color-checkmark: var(--color-primary); + opacity: 0.8; } - .photo-dash { - flex: 1 1 auto; - background-color: var(--color-white); - opacity: 0.25; - border-radius: 0.125rem; - margin: 0 0.125rem; - transition: opacity 300ms ease; - - &.current { - opacity: 0.75; - } + .emoji:not(.custom-emoji) { + width: 1.5rem; + height: 1.5rem; + background-size: 1.5rem; } - .navigation { - position: absolute; - top: 0; - bottom: 0; - width: 25%; - border: none; - padding: 0; - margin: 0; - appearance: none; - background: transparent no-repeat; - background-size: 1.25rem; - opacity: 0.25; - transition: opacity 0.15s; - outline: none; + .custom-emoji { + pointer-events: auto; cursor: pointer; - z-index: 1; - &:hover, - .is-touch-env & { - opacity: 1; - } + --custom-emoji-size: 1.5rem; - &.prev { - left: 0; - background-image: url("../../assets/media_navigation_previous.svg"); - background-position: 1.25rem 50%; - } - - &.next { - right: 0; - background-image: url("../../assets/media_navigation_next.svg"); - background-position: calc(100% - 1.25rem) 50%; - } - } - - .info { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - min-height: 100px; - padding: 0 1.5rem 0.5rem; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%); - color: var(--color-white); - display: flex; - flex-direction: column; - justify-content: flex-end; - pointer-events: none; - - &:dir(rtl) { - .status { - text-align: right; - unicode-bidi: plaintext; - } - } - - &[dir="rtl"] { - .status { - text-align: right; - unicode-bidi: plaintext; - } - } - } - - .title { - .fullName { - font-weight: 500; - font-size: 1.25rem; - line-height: 1.375rem; - white-space: pre-wrap; - word-break: break-word; - margin-bottom: 0; - } - - .VerifiedIcon, .PremiumIcon { - z-index: 2; - --color-fill: var(--color-white); - --color-checkmark: var(--color-primary); - opacity: 0.8; - } - - .emoji:not(.custom-emoji) { - width: 1.5rem; - height: 1.5rem; - background-size: 1.5rem; - } - - .custom-emoji { - pointer-events: auto; - cursor: pointer; - - --custom-emoji-size: 1.5rem; - - &.custom-color { - --emoji-status-color: var(--color-white); - } - } - } - - .status { - font-size: 0.875rem; - opacity: 0.5; - } - - &[dir="rtl"] { - .navigation.prev { - left: auto; - right: 0; - transform: scaleX(-1); - } - - .navigation.next { - left: 0; - right: auto; - transform: scaleX(-1); - } - } - - &.self { - margin: 0 -0.5rem 0.75rem; - overflow: hidden; - - &.ghost { - margin: 0; - } - - .info { - padding-bottom: 0.75rem; - } - - .status { - line-height: 1rem; + .custom-color { + --emoji-status-color: var(--color-white); } } } diff --git a/src/components/common/ProfileInfo.tsx b/src/components/common/ProfileInfo.tsx index ba6da8582..733537491 100644 --- a/src/components/common/ProfileInfo.tsx +++ b/src/components/common/ProfileInfo.tsx @@ -4,19 +4,23 @@ import React, { } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { ApiUser, ApiChat, ApiUserStatus } from '../../api/types'; +import type { + ApiUser, ApiChat, ApiUserStatus, ApiTopic, +} from '../../api/types'; import type { GlobalState } from '../../global/types'; import type { AnimationLevel } from '../../types'; import { MediaViewerOrigin } from '../../types'; +import { GENERAL_TOPIC_ID } from '../../config'; import { IS_TOUCH_ENV } from '../../util/environment'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; -import { selectChat, selectUser, selectUserStatus } from '../../global/selectors'; import { - getUserStatus, isChatChannel, isUserOnline, -} from '../../global/helpers'; + selectChat, selectCurrentMessageList, selectThreadInfo, selectUser, selectUserStatus, +} from '../../global/selectors'; +import { getUserStatus, isChatChannel, isUserOnline } from '../../global/helpers'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import buildClassName from '../../util/buildClassName'; +import renderText from './helpers/renderText'; import usePhotosPreload from './hooks/usePhotosPreload'; import useLang from '../../hooks/useLang'; @@ -25,8 +29,10 @@ import usePrevious from '../../hooks/usePrevious'; import FullNameTitle from './FullNameTitle'; import ProfilePhoto from './ProfilePhoto'; import Transition from '../ui/Transition'; +import TopicIcon from './TopicIcon'; import './ProfileInfo.scss'; +import styles from './ProfileInfo.module.scss'; type OwnProps = { userId: string; @@ -44,10 +50,13 @@ type StateProps = serverTimeOffset: number; mediaId?: number; avatarOwnerId?: string; + topic?: ApiTopic; + messagesCount?: number; } & Pick; const EMOJI_STATUS_SIZE = 24; +const EMOJI_TOPIC_SIZE = 120; const ProfileInfo: FC = ({ forceShowSelf, @@ -61,6 +70,8 @@ const ProfileInfo: FC = ({ serverTimeOffset, mediaId, avatarOwnerId, + topic, + messagesCount, }) => { const { loadFullUser, @@ -139,7 +150,7 @@ const ProfileInfo: FC = ({ // Swipe gestures useEffect(() => { - const element = document.querySelector('.photo-wrapper'); + const element = document.querySelector(`.${styles.photoWrapper}`); if (!element) { return undefined; } @@ -164,15 +175,35 @@ const ProfileInfo: FC = ({ return undefined; } + function renderTopic() { + return ( +
+ +

{renderText(topic!.title)}

+

+ {messagesCount && messagesCount > 1 + ? lang('Chat.Title.Topic', messagesCount + (topic!.id === GENERAL_TOPIC_ID ? 1 : -1), 'i') + : lang('lng_forum_no_messages')} +

+
+ ); + } + function renderPhotoTabs() { if (isSavedMessages || !photos || photos.length <= 1) { return undefined; } return ( -
+
{photos.map((_, i) => ( - + ))}
); @@ -198,14 +229,14 @@ const ProfileInfo: FC = ({ function renderStatus() { if (user) { return ( -
+
{getUserStatus(lang, user, userStatus, serverTimeOffset)}
); } return ( - { + { isChatChannel(chat!) ? lang('Subscribers', chat!.membersCount ?? 0, 'i') : lang('Members', chat!.membersCount ?? 0, 'i') @@ -214,18 +245,26 @@ const ProfileInfo: FC = ({ ); } + if (topic) { + return renderTopic(); + } + return ( -
-
+
+
{renderPhotoTabs()} - + {renderPhoto} {!isFirst && (
-
+
{(user || chat) && ( ( const isSavedMessages = !forceShowSelf && user && user.isSelf; const { animationLevel } = global.settings.byKey; const { mediaId, avatarOwnerId } = global.mediaViewer; + const isForum = chat?.isForum; + const { threadId: currentTopicId } = selectCurrentMessageList(global) || {}; + const threadInfo = currentTopicId ? selectThreadInfo(global, userId, currentTopicId) : undefined; + const topic = isForum && currentTopicId ? chat?.topics?.[currentTopicId] : undefined; return { connectionState, @@ -277,6 +321,10 @@ export default memo(withGlobal( serverTimeOffset, mediaId, avatarOwnerId, + ...(topic && { + topic, + messagesCount: threadInfo?.messagesCount, + }), }; }, )(ProfileInfo)); diff --git a/src/components/common/ProfilePhoto.scss b/src/components/common/ProfilePhoto.scss index 82f0d0bc1..ddbd89b9a 100644 --- a/src/components/common/ProfilePhoto.scss +++ b/src/components/common/ProfilePhoto.scss @@ -35,7 +35,7 @@ &.replies-bot-account, &.deleted-account, &.saved-messages { - font-size: 20rem; + font-size: 10rem; } .thumb { diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index f8596c83c..e0c89e561 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -26,7 +26,7 @@ export type OwnProps = { searchPlaceholder: string; filter?: ApiChatType[]; loadMore?: NoneToVoidFunction; - onSelectRecipient: (peerId: string) => void; + onSelectRecipient: (peerId: string, threadId?: number) => void; onClose: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction; }; @@ -94,6 +94,7 @@ const RecipientPicker: FC = ({ = ({ + topic, + className, + onClick, +}) => { + const lang = useLang(); + return ( +
+ {topic + ? + : } + {topic?.title ? renderText(topic.title) : lang('Loading')} + {topic?.isClosed && } + +
+ ); +}; + +export default memo(TopicChip); diff --git a/src/components/common/TopicDefaultIcon.module.scss b/src/components/common/TopicDefaultIcon.module.scss new file mode 100644 index 000000000..f35a53e42 --- /dev/null +++ b/src/components/common/TopicDefaultIcon.module.scss @@ -0,0 +1,30 @@ +.root { + display: inline-flex; + align-items: center; + justify-content: center; + + position: relative; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; +} + +.icon { + position: absolute; + width: 100%; + height: 100%; +} + +.title { + z-index: 1; + color: #ffffff; + font-weight: 500; + font-size: 0.75rem; + position: relative; + bottom: 0.0625rem; + + :global(.emoji) { + width: 0.75rem; + height: 0.75rem; + } +} diff --git a/src/components/common/TopicDefaultIcon.tsx b/src/components/common/TopicDefaultIcon.tsx new file mode 100644 index 000000000..02bc43246 --- /dev/null +++ b/src/components/common/TopicDefaultIcon.tsx @@ -0,0 +1,43 @@ +import React, { memo } from '../../lib/teact/teact'; + +import type { FC } from '../../lib/teact/teact'; + +import { GENERAL_TOPIC_ID } from '../../config'; +import { getFirstLetters } from '../../util/textFormat'; +import buildClassName from '../../util/buildClassName'; +import renderText from './helpers/renderText'; +import { getTopicDefaultIcon } from '../../util/forumColors'; + +import styles from './TopicDefaultIcon.module.scss'; + +type OwnProps = { + className?: string; + letterClassName?: string; + topicId: number; + iconColor?: number; + title: string; +}; + +const TopicDefaultIcon: FC = ({ + className, + letterClassName, + topicId, + iconColor, + title, +}) => { + const iconSrc = getTopicDefaultIcon(iconColor); + + if (topicId === GENERAL_TOPIC_ID) { + return ; + } + return ( +
+ +
+ {renderText(getFirstLetters(title, 1))} +
+
+ ); +}; + +export default memo(TopicDefaultIcon); diff --git a/src/components/common/TopicIcon.tsx b/src/components/common/TopicIcon.tsx new file mode 100644 index 000000000..36cb96b14 --- /dev/null +++ b/src/components/common/TopicIcon.tsx @@ -0,0 +1,52 @@ +import React, { memo } from '../../lib/teact/teact'; + +import type { FC } from '../../lib/teact/teact'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { ApiTopic } from '../../api/types'; + +import CustomEmoji from './CustomEmoji'; +import TopicDefaultIcon from './TopicDefaultIcon'; + +type OwnProps = { + topic: ApiTopic; + className?: string; + letterClassName?: string; + size?: number; + noLoopLimit?: true; + observeIntersection?: ObserveFn; +}; + +const LOOP_LIMIT = 2; + +const TopicIcon: FC = ({ + topic, + className, + letterClassName, + size, + noLoopLimit, + observeIntersection, +}) => { + if (topic.iconEmojiId) { + return ( + + ); + } + + return ( + + ); +}; + +export default memo(TopicIcon); diff --git a/src/components/common/UiLoader.module.scss b/src/components/common/UiLoader.module.scss index d731abb30..5f3936461 100644 --- a/src/components/common/UiLoader.module.scss +++ b/src/components/common/UiLoader.module.scss @@ -64,7 +64,7 @@ .left { flex: 1; background: var(--color-background); - min-width: 12rem; + min-width: 16rem; width: 33vw; max-width: 26.5rem; height: 100%; diff --git a/src/components/common/helpers/renderActionMessageText.tsx b/src/components/common/helpers/renderActionMessageText.tsx index a3cd529be..a2cfb550c 100644 --- a/src/components/common/helpers/renderActionMessageText.tsx +++ b/src/components/common/helpers/renderActionMessageText.tsx @@ -1,7 +1,7 @@ import React from '../../../lib/teact/teact'; import type { - ApiChat, ApiMessage, ApiUser, ApiGroupCall, + ApiChat, ApiMessage, ApiUser, ApiGroupCall, ApiTopic, } from '../../../api/types'; import type { TextPart } from '../../../types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; @@ -38,6 +38,7 @@ export function renderActionMessageText( targetUsers?: ApiUser[], targetMessage?: ApiMessage, targetChatId?: string, + topic?: ApiTopic, options: RenderOptions = {}, observeIntersectionForLoading?: ObserveFn, observeIntersectionForPlaying?: ObserveFn, @@ -54,6 +55,7 @@ export function renderActionMessageText( const translationKey = text === 'Chat.Service.Group.UpdatedPinnedMessage1' && !targetMessage ? 'Message.PinnedGenericMessage' : text; + let unprocessed = lang(translationKey, translationValues?.length ? translationValues : undefined); if (translationKey.includes('ScoredInGame')) { // Translation hack for games unprocessed = unprocessed.replace('un1', '%action_origin%').replace('un2', '%message%'); @@ -92,6 +94,16 @@ export function renderActionMessageText( unprocessed = processed.pop() as string; content.push(...processed); + if (unprocessed.includes('%action_topic%')) { + processed = processPlaceholder( + unprocessed, + '%action_topic%', + topic ? topic.title : 'a topic', + ); + unprocessed = processed.pop() as string; + content.push(...processed); + } + if (unprocessed.includes('%gift_payment_amount%')) { processed = processPlaceholder( unprocessed, diff --git a/src/components/left/ArchivedChats.scss b/src/components/left/ArchivedChats.scss index 86344dbd1..9a7f77b97 100644 --- a/src/components/left/ArchivedChats.scss +++ b/src/components/left/ArchivedChats.scss @@ -5,4 +5,27 @@ .chat-list { height: calc(100% - var(--header-height)); } + + .DropdownMenuFiller { + width: 2.5rem; + height: 2.5rem; + } + + .Button.rtl { + transition: var(--slide-transition) transform; + position: absolute; + z-index: 2; + + &.right-aligned { + transform: translateX(calc(clamp( + var(--left-column-min-width), + var(--left-column-width), + var(--left-column-max-width) + ) - 4.375rem)); + } + + &.disable-transition { + transition: none; + } + } } diff --git a/src/components/left/ArchivedChats.tsx b/src/components/left/ArchivedChats.tsx index f95594aa4..a774f70a7 100644 --- a/src/components/left/ArchivedChats.tsx +++ b/src/components/left/ArchivedChats.tsx @@ -1,22 +1,33 @@ -import type { FC } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact'; +import type { FC } from '../../lib/teact/teact'; + +import buildClassName from '../../util/buildClassName'; import useLang from '../../hooks/useLang'; import useHistoryBack from '../../hooks/useHistoryBack'; +import useLeftHeaderButtonRtlForumTransition from './main/hooks/useLeftHeaderButtonRtlForumTransition'; +import useShowTransition from '../../hooks/useShowTransition'; +import useForumPanelRender from '../../hooks/useForumPanelRender'; import Button from '../ui/Button'; import ChatList from './main/ChatList'; -import type { LeftColumnContent } from '../../types'; +import ForumPanel from './main/ForumPanel'; import './ArchivedChats.scss'; export type OwnProps = { isActive: boolean; onReset: () => void; - onContentChange: (content: LeftColumnContent) => void; + onTopicSearch: NoneToVoidFunction; + isForumPanelOpen?: boolean; }; -const ArchivedChats: FC = ({ isActive, onReset }) => { +const ArchivedChats: FC = ({ + isActive, + isForumPanelOpen, + onReset, + onTopicSearch, +}) => { const lang = useLang(); useHistoryBack({ @@ -24,21 +35,47 @@ const ArchivedChats: FC = ({ isActive, onReset }) => { onBack: onReset, }); + const { + shouldDisableDropdownMenuTransitionRef, + handleDropdownMenuTransitionEnd, + } = useLeftHeaderButtonRtlForumTransition(isForumPanelOpen); + + const { + shouldRender: shouldRenderTitle, + transitionClassNames: titleClassNames, + } = useShowTransition(!isForumPanelOpen); + + const { shouldRenderForumPanel, handleForumPanelAnimationEnd } = useForumPanelRender(isForumPanelOpen); + return (
+ {lang.isRtl &&
} -

{lang('ArchivedChats')}

+ {shouldRenderTitle &&

{lang('ArchivedChats')}

}
+ {shouldRenderForumPanel && ( + + )}
); }; diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index ab6a30b78..7d572eb33 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -8,7 +8,7 @@ import { LeftColumnContent, SettingsScreens } from '../../types'; import { IS_MAC_OS, IS_PWA, LAYERS_ANIMATION_NAME } from '../../util/environment'; import captureEscKeyListener from '../../util/captureEscKeyListener'; -import { selectCurrentChat } from '../../global/selectors'; +import { selectCurrentChat, selectIsForumPanelOpen } from '../../global/selectors'; import useFoldersReducer from '../../hooks/reducers/useFoldersReducer'; import { useResize } from '../../hooks/useResize'; import { useHotkeys } from '../../hooks/useHotkeys'; @@ -33,6 +33,9 @@ type StateProps = { nextSettingsScreen?: SettingsScreens; isChatOpen: boolean; isUpdateAvailable?: boolean; + isForumPanelOpen?: boolean; + forumPanelChatId?: string; + isClosingSearch?: boolean; }; enum ContentType { @@ -60,9 +63,13 @@ const LeftColumn: FC = ({ nextSettingsScreen, isChatOpen, isUpdateAvailable, + isForumPanelOpen, + forumPanelChatId, + isClosingSearch, }) => { const { setGlobalSearchQuery, + setGlobalSearchClosing, setGlobalSearchChatId, resetChatCreation, setGlobalSearchDate, @@ -106,11 +113,13 @@ const LeftColumn: FC = ({ function fullReset() { setContent(LeftColumnContent.ChatList); setContactsFilter(''); - setGlobalSearchQuery({ query: '' }); - setGlobalSearchDate({ date: undefined }); - setGlobalSearchChatId({ id: undefined }); + setGlobalSearchClosing(true); resetChatCreation(); setTimeout(() => { + setGlobalSearchQuery({ query: '' }); + setGlobalSearchDate({ date: undefined }); + setGlobalSearchChatId({ id: undefined }); + setGlobalSearchClosing(false); setLastResetTime(Date.now()); }, RESET_TRANSITION_DELAY_MS); } @@ -299,8 +308,8 @@ const LeftColumn: FC = ({ fullReset(); }, [ - content, isFirstChatFolderActive, settingsScreen, setGlobalSearchQuery, setGlobalSearchDate, setGlobalSearchChatId, - resetChatCreation, hasPasscode, + content, isFirstChatFolderActive, setGlobalSearchClosing, resetChatCreation, setGlobalSearchQuery, + setGlobalSearchDate, setGlobalSearchChatId, settingsScreen, hasPasscode, ]); const handleSearchQuery = useCallback((query: string) => { @@ -316,6 +325,12 @@ const LeftColumn: FC = ({ } }, [content, searchQuery, setGlobalSearchQuery]); + const handleTopicSearch = useCallback(() => { + setContent(LeftColumnContent.GlobalSearch); + setGlobalSearchQuery({ query: '' }); + setGlobalSearchChatId({ id: forumPanelChatId }); + }, [forumPanelChatId, setGlobalSearchChatId, setGlobalSearchQuery]); + useEffect( () => (content !== LeftColumnContent.ChatList || (isFirstChatFolderActive && !isChatOpen) ? captureEscKeyListener(() => handleReset()) @@ -367,7 +382,7 @@ const LeftColumn: FC = ({ const { initResize, resetResize, handleMouseUp, - } = useResize(resizeRef, setLeftColumnWidth, resetLeftColumnWidth, leftColumnWidth); + } = useResize(resizeRef, setLeftColumnWidth, resetLeftColumnWidth, leftColumnWidth, '--left-column-width'); const handleSettingsScreenSelect = useCallback((screen: SettingsScreens) => { setContent(LeftColumnContent.Settings); @@ -393,7 +408,8 @@ const LeftColumn: FC = ({ ); case ContentType.Settings: @@ -433,6 +449,7 @@ const LeftColumn: FC = ({ return ( = ({ onReset={handleReset} shouldSkipTransition={shouldSkipHistoryAnimations} isUpdateAvailable={isUpdateAvailable} + isForumPanelOpen={isForumPanelOpen} + onTopicSearch={handleTopicSearch} /> ); } @@ -480,7 +499,10 @@ export default memo(withGlobal( isUpdateAvailable, } = global; - const isChatOpen = Boolean(selectCurrentChat(global)?.id); + const currentChat = selectCurrentChat(global); + const isChatOpen = Boolean(currentChat?.id); + const isForumPanelOpen = selectIsForumPanelOpen(global); + const forumPanelChatId = global.forumPanelChatId; return { searchQuery: query, @@ -493,6 +515,9 @@ export default memo(withGlobal( nextSettingsScreen, isChatOpen, isUpdateAvailable, + isForumPanelOpen, + forumPanelChatId, + isClosingSearch: global.globalSearch.isClosing, }; }, )(LeftColumn)); diff --git a/src/components/left/main/Badge.scss b/src/components/left/main/Badge.scss index 001bd24ad..a625bc597 100644 --- a/src/components/left/main/Badge.scss +++ b/src/components/left/main/Badge.scss @@ -49,11 +49,21 @@ } &.mention, - &.unread:not(.muted) { + &.unread:not(.muted), + &.unopened:not(.muted) { background: var(--color-green); color: var(--color-white); } + &.unopened { + width: 0.5rem; + height: 0.5rem; + min-width: auto; + min-height: auto; + padding: 0; + align-self: center; + } + &.pinned { color: var(--color-pinned); background: transparent; diff --git a/src/components/left/main/Badge.tsx b/src/components/left/main/Badge.tsx index 575c648b1..2aff39634 100644 --- a/src/components/left/main/Badge.tsx +++ b/src/components/left/main/Badge.tsx @@ -1,7 +1,7 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo } from '../../../lib/teact/teact'; +import React, { memo, useMemo } from '../../../lib/teact/teact'; -import type { ApiChat } from '../../../api/types'; +import type { ApiChat, ApiTopic } from '../../../api/types'; +import type { FC } from '../../../lib/teact/teact'; import { formatIntegerCompact } from '../../../util/textFormat'; import buildClassName from '../../../util/buildClassName'; @@ -13,38 +13,76 @@ import './Badge.scss'; type OwnProps = { chat: ApiChat; + topic?: ApiTopic; + wasTopicOpened?: boolean; isPinned?: boolean; isMuted?: boolean; + shouldShowOnlyMostImportant?: boolean; }; -const Badge: FC = ({ chat, isPinned, isMuted }) => { +const Badge: FC = ({ + topic, chat, isPinned, isMuted, shouldShowOnlyMostImportant, wasTopicOpened, +}) => { + const { + unreadMentionsCount = 0, unreadReactionsCount = 0, + } = !chat.isForum ? chat : {}; // TODO[forums] Unread mentions and reactions temporarily disabled for forums + + const isTopicUnopened = !isPinned && topic && !wasTopicOpened; + const isForum = chat.isForum && !topic; + const topicsWithUnread = useMemo(() => ( + isForum && chat?.topics ? Object.values(chat.topics).filter(({ unreadCount }) => unreadCount) : undefined + ), [chat, isForum]); + + const unreadCount = useMemo(() => ( + isForum + // If we have unmuted topics, display the count of those. Otherwise, display the count of all topics. + ? ((isMuted && topicsWithUnread?.filter((acc) => acc.isMuted === false).length) + || topicsWithUnread?.length) + : (topic || chat).unreadCount + ), [chat, topic, topicsWithUnread, isForum, isMuted]); + + const shouldBeMuted = useMemo(() => { + const hasUnmutedUnreadTopics = chat.topics + && Object.values(chat.topics).some((acc) => acc.isMuted && acc.unreadCount); + + return isMuted || (chat.topics && !hasUnmutedUnreadTopics); + }, [chat, isMuted]); + + const hasUnreadMark = topic ? false : chat.hasUnreadMark; + const isShown = Boolean( - chat.unreadCount || chat.unreadMentionsCount || chat.hasUnreadMark || isPinned || chat.unreadReactionsCount, + unreadCount || unreadMentionsCount || hasUnreadMark || isPinned || unreadReactionsCount + || isTopicUnopened, ); - const isUnread = Boolean(chat.unreadCount || chat.hasUnreadMark); + + const isUnread = Boolean(unreadCount || hasUnreadMark); const className = buildClassName( 'Badge', - isMuted && 'muted', + shouldBeMuted && 'muted', !isUnread && isPinned && 'pinned', isUnread && 'unread', ); function renderContent() { - const unreadReactionsElement = chat.unreadReactionsCount && ( -
+ const unreadReactionsElement = unreadReactionsCount && ( +
); - const unreadMentionsElement = chat.unreadMentionsCount && ( + const unreadMentionsElement = unreadMentionsCount && (
); - const unreadCountElement = (chat.hasUnreadMark || chat.unreadCount) ? ( + const unopenedTopicElement = isTopicUnopened && ( +
+ ); + + const unreadCountElement = (hasUnreadMark || unreadCount) ? (
- {!chat.hasUnreadMark && } + {!hasUnreadMark && }
) : undefined; @@ -54,12 +92,21 @@ const Badge: FC = ({ chat, isPinned, isMuted }) => {
); - const elements = [unreadReactionsElement, unreadMentionsElement, unreadCountElement, pinnedElement].filter(Boolean); + const elements = [ + unopenedTopicElement, unreadReactionsElement, unreadMentionsElement, unreadCountElement, pinnedElement, + ].filter(Boolean); if (elements.length === 0) return undefined; if (elements.length === 1) return elements[0]; + if (shouldShowOnlyMostImportant) { + const importanceOrderedElements = [ + unreadMentionsElement, unreadCountElement, unreadReactionsElement, pinnedElement, + ].filter(Boolean); + return importanceOrderedElements[0]; + } + return (
{elements} diff --git a/src/components/left/main/Chat.scss b/src/components/left/main/Chat.scss index 36577cd43..55856364f 100644 --- a/src/components/left/main/Chat.scss +++ b/src/components/left/main/Chat.scss @@ -28,6 +28,14 @@ .Avatar.online::after { border-color: var(--color-chat-hover); } + + .status-badge-wrapper { + --outline-color: var(--color-chat-hover); + } + + .ChatCallStatus { + border-color: var(--color-chat-hover); + } } &:last-of-type { @@ -41,12 +49,29 @@ .Avatar.online::after { border-color: var(--color-chat-hover); } + + .ChatCallStatus { + border-color: var(--color-chat-hover); + } + } + + &.active-forum { + .status-badge-wrapper { + --outline-color: var(--color-chat-hover); + } } } @media (min-width: 600px) { - &.selected, - &.selected:hover { + &.active-forum.forum, + &.active-forum.forum:hover { + .status-badge-wrapper { + --outline-color: var(--color-chat-hover); + } + } + + &.selected:not(.forum), + &.selected:not(.forum):hover { --background-color: var(--color-chat-active) !important; .custom-emoji.custom-color { @@ -63,6 +88,10 @@ background: var(--color-white); } + .ChatCallStatus { + border-color: var(--color-chat-active) !important; + } + .ListItem-button { --background-color: var(--color-chat-active) !important; --color-text: var(--color-white); @@ -77,25 +106,98 @@ color: var(--color-white) !important; } + .general-forum-icon { + color: var(--color-white) !important; + } + .Badge:not(.pinned) { background: var(--color-white); color: var(--color-chat-active); } + + .Badge:not(.pinned).muted { + color: var(--color-white); + background: #FFFFFF33; + } + + .status-badge-wrapper-visible .Badge:not(.pinned).muted { + background: var(--color-chat-active-greyed); + --outline-color: transparent; + } + + .status-badge-wrapper-visible .Badge:not(.pinned):not(.muted) { + --outline-color: transparent; + } } } + &.smaller .ListItem-button { + height: 4.5rem; + } + + &.active-forum::before { + content: ''; + position: absolute; + top: 50%; + left: -0.5rem; + width: 0.375rem; + height: 75%; + transform: translateY(-50%); + + background: var(--color-primary); + z-index: 1; + + border-start-end-radius: var(--border-radius-default); + border-end-end-radius: var(--border-radius-default); + } + @media (max-width: 600px) { .ListItem-button { border-radius: 0 !important; } } + .ripple-container { + z-index: 2; + } + .status { + height: 3.375rem; position: relative; flex-shrink: 0; + z-index: 1; + background: var(--background-color); + } + + .status-badge-wrapper { + position: absolute; + bottom: 0; + right: 0.5rem; + z-index: 2; + + transition-duration: 0.25s; + transition-timing-function: cubic-bezier(0.16,1.25,0.64,1); + transition-property: opacity, transform; + opacity: 0; + transform: scale(0); + + --outline-color: var(--color-background); + + .Badge { + box-shadow: 0 0 0 2px var(--outline-color); + } + } + + .status-badge-wrapper-visible { + opacity: 1; + transform: scale(1); } .info { + transition-duration: 0.25s; + transition-property: ease-in-out; + transition-property: opacity, transform; + .subtitle { margin-top: -0.125rem; } @@ -112,6 +214,11 @@ } } + .general-forum-icon { + font-size: 1.25rem; + color: var(--color-text-secondary); + } + .LastMessageMeta { body.is-ios & { font-size: 0.875rem; @@ -218,5 +325,19 @@ unicode-bidi: plaintext; } } + + &.active-forum::before { + left: auto; + right: 0.0625rem; + } + } + + &.smaller .info { + transform: translateX(-25%); + opacity: 0; + } + + &[dir="rtl"].smaller .info { + transform: translateX(25%); } } diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index a811195dc..014eeef5a 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -1,52 +1,58 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useLayoutEffect, useMemo, useRef, + memo, useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; -import { getActions, getGlobal, withGlobal } from '../../../global'; +import { getActions, withGlobal } from '../../../global'; -import type { LangFn } from '../../../hooks/useLang'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { - ApiChat, ApiUser, ApiMessage, ApiMessageOutgoingStatus, ApiFormattedText, ApiUserStatus, + ApiChat, + ApiUser, + ApiMessage, + ApiMessageOutgoingStatus, + ApiFormattedText, + ApiUserStatus, + ApiTopic, + ApiTypingStatus, } from '../../../api/types'; import type { AnimationLevel } from '../../../types'; -import { MAIN_THREAD_ID } from '../../../api/types'; +import type { ChatAnimationTypes } from './hooks'; import { ANIMATION_END_DELAY } from '../../../config'; +import { MAIN_THREAD_ID } from '../../../api/types'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { isUserId, - isActionMessage, getPrivateChatUserId, getMessageAction, - getMessageSenderName, - isChatChannel, - getMessageMediaHash, - getMessageMediaThumbDataUri, - getMessageVideo, - getMessageSticker, selectIsChatMuted, - getMessageRoundVideo, } from '../../../global/helpers'; import { - selectChat, selectUser, selectChatMessage, selectOutgoingStatus, selectDraft, selectCurrentMessageList, - selectNotifySettings, selectNotifyExceptions, selectUserStatus, selectIsDefaultEmojiStatusPack, + selectChat, + selectUser, + selectChatMessage, + selectOutgoingStatus, + selectDraft, + selectCurrentMessageList, + selectNotifySettings, + selectNotifyExceptions, + selectUserStatus, + selectIsDefaultEmojiStatusPack, + selectTopicFromMessage, + selectThreadParam, + selectIsForumPanelOpen, } from '../../../global/selectors'; -import { renderActionMessageText } from '../../common/helpers/renderActionMessageText'; -import renderText from '../../common/helpers/renderText'; -import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; -import { fastRaf } from '../../../util/schedulers'; import buildClassName from '../../../util/buildClassName'; +import { fastRaf } from '../../../util/schedulers'; +import buildStyle from '../../../util/buildStyle'; -import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useChatContextActions from '../../../hooks/useChatContextActions'; import useFlag from '../../../hooks/useFlag'; -import useMedia from '../../../hooks/useMedia'; -import { ChatAnimationTypes } from './hooks'; -import useLang from '../../../hooks/useLang'; +import useChatListEntry from './hooks/useChatListEntry'; +import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; +import usePrevious from '../../../hooks/usePrevious'; import Avatar from '../../common/Avatar'; -import TypingStatus from '../../common/TypingStatus'; import LastMessageMeta from '../../common/LastMessageMeta'; import DeleteChatModal from '../../common/DeleteChatModal'; import ListItem from '../../ui/ListItem'; @@ -55,17 +61,19 @@ import ChatFolderModal from '../ChatFolderModal.async'; import ChatCallStatus from './ChatCallStatus'; import ReportModal from '../../common/ReportModal'; import FullNameTitle from '../../common/FullNameTitle'; -import MessageSummary from '../../common/MessageSummary'; import './Chat.scss'; +const TRANSFORM_TO_TOPIC_LIST_ANIMATION_DELAY = 300; + type OwnProps = { - style?: string; chatId: string; folderId?: number; orderDiff: number; animationType: ChatAnimationTypes; isPinned?: boolean; + offsetTopInSmallerMode: number; + offsetTop: number; observeIntersection?: ObserveFn; onDragEnter?: (chatId: string) => void; }; @@ -79,20 +87,21 @@ type StateProps = { actionTargetUserIds?: string[]; actionTargetMessage?: ApiMessage; actionTargetChatId?: string; - lastMessageSender?: ApiUser; + lastMessageSender?: ApiUser | ApiChat; lastMessageOutgoingStatus?: ApiMessageOutgoingStatus; draft?: ApiFormattedText; animationLevel?: AnimationLevel; isSelected?: boolean; + isForumPanelActive?: boolean; canScrollDown?: boolean; canChangeFolder?: boolean; lastSyncTime?: number; + lastMessageTopic?: ApiTopic; + typingStatus?: ApiTypingStatus; + forumPanelChatId?: string; }; -const ANIMATION_DURATION = 200; - const Chat: FC = ({ - style, chatId, folderId, orderDiff, @@ -109,22 +118,28 @@ const Chat: FC = ({ lastMessageOutgoingStatus, actionTargetMessage, actionTargetChatId, + offsetTopInSmallerMode, + offsetTop, draft, animationLevel, isSelected, + isForumPanelActive, canScrollDown, canChangeFolder, lastSyncTime, + lastMessageTopic, + typingStatus, + forumPanelChatId, onDragEnter, }) => { const { openChat, + openForumPanel, + closeForumPanel, focusLastMessage, + loadTopics, } = getActions(); - // eslint-disable-next-line no-null/no-null - const ref = useRef(null); - const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); const [isChatFolderModalOpen, openChatFolderModal, closeChatFolderModal] = useFlag(); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); @@ -132,74 +147,40 @@ const Chat: FC = ({ const [shouldRenderChatFolderModal, markRenderChatFolderModal, unmarkRenderChatFolderModal] = useFlag(); const [shouldRenderReportModal, markRenderReportModal, unmarkRenderReportModal] = useFlag(); - const { lastMessage, typingStatus } = chat || {}; - const isAction = lastMessage && isActionMessage(lastMessage); + const { lastMessage, isForum } = chat || {}; - useEnsureMessage(chatId, isAction ? lastMessage.replyToMessageId : undefined, actionTargetMessage); + const { renderSubtitle, ref } = useChatListEntry({ + chat, + chatId, + lastMessage, + typingStatus, + draft, + actionTargetMessage, + actionTargetUserIds, + actionTargetChatId, + lastMessageTopic, + lastMessageSender, + observeIntersection, - const mediaThumbnail = lastMessage && !getMessageSticker(lastMessage) - ? getMessageMediaThumbDataUri(lastMessage) - : undefined; - const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined); - const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); - - const actionTargetUsers = useMemo(() => { - if (!actionTargetUserIds) { - return undefined; - } - - // No need for expensive global updates on users, so we avoid them - const usersById = getGlobal().users.byId; - return actionTargetUserIds.map((userId) => usersById[userId]).filter(Boolean); - }, [actionTargetUserIds]); - - // Sets animation excess values when `orderDiff` changes and then resets excess values to animate. - useLayoutEffect(() => { - const element = ref.current; - - if (animationLevel === 0 || !element) { - return; - } - - // TODO Refactor animation: create `useListAnimation` that owns `orderDiff` and `animationType` - if (animationType === ChatAnimationTypes.Opacity) { - element.style.opacity = '0'; - - fastRaf(() => { - element.classList.add('animate-opacity'); - element.style.opacity = '1'; - }); - } else if (animationType === ChatAnimationTypes.Move) { - element.style.transform = `translate3d(0, ${-orderDiff * 100}%, 0)`; - - fastRaf(() => { - element.classList.add('animate-transform'); - element.style.transform = ''; - }); - } else { - return; - } - - setTimeout(() => { - fastRaf(() => { - element.classList.remove('animate-opacity', 'animate-transform'); - element.style.opacity = ''; - element.style.transform = ''; - }); - }, ANIMATION_DURATION + ANIMATION_END_DELAY); - }, [animationLevel, orderDiff, animationType]); + animationType, + animationLevel, + orderDiff, + }); const handleClick = useCallback(() => { + if (chat?.isForum) { + openForumPanel({ chatId }); + return; + } + + if (forumPanelChatId) closeForumPanel(); openChat({ id: chatId, shouldReplaceHistory: true }, { forceOnHeavyAnimation: true }); if (isSelected && canScrollDown) { focusLastMessage(); } }, [ - isSelected, - canScrollDown, - openChat, - chatId, + chat?.isForum, forumPanelChatId, closeForumPanel, openChat, chatId, isSelected, canScrollDown, openForumPanel, focusLastMessage, ]); @@ -235,79 +216,66 @@ const Chat: FC = ({ canChangeFolder, }); - const lang = useLang(); + const isIntersecting = useIsIntersecting(ref, observeIntersection); + + // Load the forum topics to display unread count badge + useEffect(() => { + if (isIntersecting && lastSyncTime && isForum && chat && chat.topics === undefined) { + loadTopics({ chatId }); + } + }, [chat, chatId, isForum, isIntersecting, lastSyncTime, loadTopics]); + + const isOnForumPanel = chatId === forumPanelChatId; + const prevIsForumPanelActive = usePrevious(isForumPanelActive); + const isAnimatingRef = useRef(false); + + if (prevIsForumPanelActive !== isForumPanelActive) { + isAnimatingRef.current = true; + } + + // Animate changing to smaller chat size when navigating to/from forum topic list + useEffect(() => { + const current = ref.current; + + if (current && isAnimatingRef.current && isForumPanelActive !== prevIsForumPanelActive) { + current.classList.add('animate-transform'); + current.style.transform = ''; + setTimeout(() => { + // Wait one more frame for better animation performance + fastRaf(() => { + isAnimatingRef.current = false; + current.classList.remove('animate-transform'); + }); + }, TRANSFORM_TO_TOPIC_LIST_ANIMATION_DELAY + ANIMATION_END_DELAY); + } + }, [ref, isForumPanelActive, prevIsForumPanelActive]); if (!chat) { return undefined; } - function renderLastMessageOrTyping() { - if (typingStatus && lastMessage && typingStatus.timestamp > lastMessage.date * 1000) { - return ; - } - - if (draft?.text.length) { - return ( -

- {lang('Draft')} - {renderTextWithEntities(draft.text, draft.entities, undefined, undefined, undefined, undefined, true)} -

- ); - } - - if (!lastMessage) { - return undefined; - } - - if (isAction) { - const isChat = chat && (isChatChannel(chat) || lastMessage.senderId === lastMessage.chatId); - - return ( -

- {renderActionMessageText( - lang, - lastMessage, - !isChat ? lastMessageSender : undefined, - isChat ? chat : undefined, - actionTargetUsers, - actionTargetMessage, - actionTargetChatId, - { isEmbedded: true }, - )} -

- ); - } - - const senderName = getMessageSenderName(lang, chatId, lastMessageSender); - - return ( -

- {senderName && ( - <> - {renderText(senderName)} - : - - )} - {renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)} -

- ); - } - const className = buildClassName( 'Chat chat-item-clickable', isUserId(chatId) ? 'private' : 'group', + isForum && 'forum', isSelected && 'selected', + isForumPanelActive && 'smaller', + isOnForumPanel && 'active-forum', ); + const chatTop = isForumPanelActive ? (offsetTop - offsetTopInSmallerMode) : offsetTop; + const offsetAnimate = isForumPanelActive ? offsetTopInSmallerMode : -offsetTopInSmallerMode; + return (
= ({ withVideo observeIntersection={observeIntersection} /> +
+ +
{chat.isCallActive && chat.isCallNotEmpty && ( )} @@ -343,7 +318,7 @@ const Chat: FC = ({ )}
- {renderLastMessageOrTyping()} + {renderSubtitle()}
@@ -376,31 +351,6 @@ const Chat: FC = ({ ); }; -function renderSummary( - lang: LangFn, message: ApiMessage, observeIntersection?: ObserveFn, blobUrl?: string, isRoundVideo?: boolean, -) { - const messageSummary = ( - - ); - - if (!blobUrl) { - return messageSummary; - } - - return ( - - - {getMessageVideo(message) && } - {messageSummary} - - ); -} - export default memo(withGlobal( (global, { chatId }): StateProps => { const chat = selectChat(global, chatId); @@ -409,7 +359,8 @@ export default memo(withGlobal( } const { senderId, replyToMessageId, isOutgoing } = chat.lastMessage || {}; - const lastMessageSender = senderId ? selectUser(global, senderId) : undefined; + const lastMessageSender = senderId + ? (selectUser(global, senderId) || selectChat(global, senderId)) : undefined; const lastMessageAction = chat.lastMessage ? getMessageAction(chat.lastMessage) : undefined; const actionTargetMessage = lastMessageAction && replyToMessageId ? selectChatMessage(global, chat.id, replyToMessageId) @@ -421,12 +372,16 @@ export default memo(withGlobal( threadId: currentThreadId, type: messageListType, } = selectCurrentMessageList(global) || {}; + const isForumPanelActive = selectIsForumPanelOpen(global); const isSelected = chatId === currentChatId && currentThreadId === MAIN_THREAD_ID; const user = privateChatUserId ? selectUser(global, privateChatUserId) : undefined; const userStatus = privateChatUserId ? selectUserStatus(global, privateChatUserId) : undefined; const statusEmoji = user?.emojiStatus && global.customEmojis.byId[user.emojiStatus.documentId]; const isEmojiStatusColored = statusEmoji && selectIsDefaultEmojiStatusPack(global, statusEmoji.stickerSetInfo); + const lastMessageTopic = chat.lastMessage && selectTopicFromMessage(global, chat.lastMessage); + + const typingStatus = selectThreadParam(global, chatId, MAIN_THREAD_ID, 'typingStatus'); return { chat, @@ -437,6 +392,7 @@ export default memo(withGlobal( actionTargetMessage, draft: selectDraft(global, chatId, MAIN_THREAD_ID), animationLevel: global.settings.byKey.animationLevel, + isForumPanelActive, isSelected, canScrollDown: isSelected && messageListType === 'thread', canChangeFolder: (global.chatFolders.orderedIds?.length || 0) > 1, @@ -447,6 +403,9 @@ export default memo(withGlobal( user, userStatus, isEmojiStatusColored, + lastMessageTopic, + typingStatus, + forumPanelChatId: global.forumPanelChatId, }; }, )(Chat)); diff --git a/src/components/left/main/ChatFolders.tsx b/src/components/left/main/ChatFolders.tsx index a1cb459c1..4448211a3 100644 --- a/src/components/left/main/ChatFolders.tsx +++ b/src/components/left/main/ChatFolders.tsx @@ -26,6 +26,7 @@ import ChatList from './ChatList'; type OwnProps = { onScreenSelect: (screen: SettingsScreens) => void; foldersDispatch: FolderEditDispatch; + shouldHideFolderTabs?: boolean; }; type StateProps = { @@ -51,6 +52,7 @@ const ChatFolders: FC = ({ lastSyncTime, shouldSkipHistoryAnimations, maxFolders, + shouldHideFolderTabs, }) => { const { loadChatFolders, @@ -221,7 +223,12 @@ const ChatFolders: FC = ({ const shouldRenderFolders = folderTabs && folderTabs.length > 1; return ( -
+
{shouldRenderFolders ? ( ) : shouldRenderPlaceholder ? ( diff --git a/src/components/left/main/ChatList.tsx b/src/components/left/main/ChatList.tsx index 6ade0a8cb..e5740649e 100644 --- a/src/components/left/main/ChatList.tsx +++ b/src/components/left/main/ChatList.tsx @@ -1,7 +1,7 @@ import React, { - memo, useMemo, useEffect, useRef, useCallback, + memo, useEffect, useRef, useCallback, useMemo, } from '../../../lib/teact/teact'; -import { getActions } from '../../../global'; +import { getActions, getGlobal } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; import type { SettingsScreens } from '../../../types'; @@ -9,21 +9,20 @@ import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReduc import { ALL_FOLDER_ID, - ARCHIVED_FOLDER_ID, + ARCHIVED_FOLDER_ID, CHAT_HEIGHT_FORUM_PX, CHAT_HEIGHT_PX, CHAT_LIST_SLICE, } from '../../../config'; import { IS_MAC_OS, IS_PWA } from '../../../util/environment'; -import { mapValues } from '../../../util/iteratees'; import { getPinnedChatsCount, getOrderKey } from '../../../util/folderManager'; +import { selectChat } from '../../../global/selectors'; -import usePrevious from '../../../hooks/usePrevious'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import { useFolderManagerForOrderedIds } from '../../../hooks/useFolderManager'; -import { useChatAnimationType } from './hooks'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import { useHotkeys } from '../../../hooks/useHotkeys'; import useDebouncedCallback from '../../../hooks/useDebouncedCallback'; +import useChatOrderDiff from './hooks/useChatOrderDiff'; import InfiniteScroll from '../../ui/InfiniteScroll'; import Loading from '../../ui/Loading'; @@ -60,28 +59,7 @@ const ChatList: FC = ({ const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId); - const orderById = useMemo(() => { - if (!orderedIds) { - return undefined; - } - - return orderedIds.reduce((acc, id, i) => { - acc[id] = i; - return acc; - }, {} as Record); - }, [orderedIds]); - - const prevOrderById = usePrevious(orderById); - - const orderDiffById = useMemo(() => { - if (!orderById || !prevOrderById) { - return {}; - } - - return mapValues(orderById, (order, id) => { - return prevOrderById[id] !== undefined ? order - prevOrderById[id] : -Infinity; - }); - }, [orderById, prevOrderById]); + const { orderDiffById, getAnimationType } = useChatOrderDiff(orderedIds); const [viewportIds, getMore] = useInfiniteScroll(undefined, orderedIds, undefined, CHAT_LIST_SLICE); @@ -122,8 +100,6 @@ const ChatList: FC = ({ }; }, [isActive, openChat, openNextChat, orderedIds]); - const getAnimationType = useChatAnimationType(orderDiffById); - const { observe } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE, @@ -145,12 +121,31 @@ const ChatList: FC = ({ shouldIgnoreDragRef.current = true; }, []); + const viewportOffsetPx = useMemo(() => { + if (!viewportIds?.length) return 0; + const global = getGlobal(); + const viewportOffset = orderedIds!.indexOf(viewportIds![0]); + return orderedIds!.reduce((acc, id, i) => { + if (i >= viewportOffset) { + return acc; + } + return acc + (selectChat(global, id)!.isForum ? CHAT_HEIGHT_FORUM_PX : CHAT_HEIGHT_PX); + }, 0); + }, [orderedIds, viewportIds]); + function renderChats() { const viewportOffset = orderedIds!.indexOf(viewportIds![0]); + const global = getGlobal(); + const pinnedCount = getPinnedChatsCount(resolvedFolderId) || 0; + let currentChatListHeight = viewportOffsetPx; + return viewportIds!.map((id, i) => { const isPinned = viewportOffset + i < pinnedCount; + const chatTop = currentChatListHeight; + const chatTopSmaller = (viewportOffset + i) * CHAT_HEIGHT_PX; + currentChatListHeight += (selectChat(global, id)!.isForum ? CHAT_HEIGHT_FORUM_PX : CHAT_HEIGHT_PX); return ( = ({ folderId={folderId} animationType={getAnimationType(id)} orderDiff={orderDiffById[id]} - style={`top: ${(viewportOffset + i) * CHAT_HEIGHT_PX}px;`} + offsetTop={chatTop} + offsetTopInSmallerMode={chatTop - chatTopSmaller} observeIntersection={observe} onDragEnter={handleDragEnter} /> diff --git a/src/components/left/main/EmptyFolder.scss b/src/components/left/main/EmptyFolder.module.scss similarity index 70% rename from src/components/left/main/EmptyFolder.scss rename to src/components/left/main/EmptyFolder.module.scss index 482edaf86..adbf02b60 100644 --- a/src/components/left/main/EmptyFolder.scss +++ b/src/components/left/main/EmptyFolder.module.scss @@ -1,4 +1,4 @@ -.EmptyFolder { +.root { width: 100%; height: 80%; display: flex; @@ -11,13 +11,16 @@ } .sticker { - height: 8rem; + height: 6rem; margin-bottom: 1.875rem; } .title { font-size: 1.25rem; margin-bottom: 0.125rem; + word-break: break-word; + text-align: center; + max-width: 100%; } .description { @@ -30,12 +33,21 @@ } } - .Button.pill { + :global(.Button.pill) { + max-width: 100%; margin-top: 0.625rem; font-weight: 500; padding-inline-start: 0.75rem; unicode-bidi: plaintext; + justify-content: start; + + .button-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + i { margin-inline-end: 0.625rem; font-size: 1.5rem; diff --git a/src/components/left/main/EmptyFolder.tsx b/src/components/left/main/EmptyFolder.tsx index 17b6f4aba..f16cc0dab 100644 --- a/src/components/left/main/EmptyFolder.tsx +++ b/src/components/left/main/EmptyFolder.tsx @@ -13,7 +13,7 @@ import useLang from '../../../hooks/useLang'; import Button from '../../ui/Button'; import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker'; -import './EmptyFolder.scss'; +import styles from './EmptyFolder.module.scss'; type OwnProps = { folderId?: number; @@ -27,7 +27,7 @@ type StateProps = { animatedEmoji?: ApiSticker; }; -const ICON_SIZE = 128; +const ICON_SIZE = 96; const EmptyFolder: FC = ({ chatFolder, animatedEmoji, foldersDispatch, onScreenSelect, @@ -40,12 +40,12 @@ const EmptyFolder: FC = ({ }, [chatFolder, foldersDispatch, onScreenSelect]); return ( -
-
+
+
{animatedEmoji && }
-

{lang('FilterNoChatsToDisplay')}

-

+

{lang('FilterNoChatsToDisplay')}

+

{lang(chatFolder ? 'ChatList.EmptyChatListFilterText' : 'Chat.EmptyChat')}

{chatFolder && foldersDispatch && onScreenSelect && ( @@ -58,7 +58,9 @@ const EmptyFolder: FC = ({ isRtl={lang.isRtl} > - {lang('ChatList.EmptyChatListEditFilter')} +
+ {lang('ChatList.EmptyChatListEditFilter')} +
)}
diff --git a/src/components/left/main/EmptyTopic.tsx b/src/components/left/main/EmptyTopic.tsx new file mode 100644 index 000000000..f09924432 --- /dev/null +++ b/src/components/left/main/EmptyTopic.tsx @@ -0,0 +1,58 @@ +import type { FC } from '../../../lib/teact/teact'; +import React, { memo, useCallback } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; + +import type { ApiSticker } from '../../../api/types'; + +import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; +import { selectAnimatedEmoji } from '../../../global/selectors'; +import useLang from '../../../hooks/useLang'; + +import Button from '../../ui/Button'; +import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker'; + +import styles from './EmptyFolder.module.scss'; + +type StateProps = { + animatedEmoji?: ApiSticker; +}; + +const ICON_SIZE = 96; + +// TODO[forums] Open create topic screen if has permission +const EmptyTopic: FC = ({ + animatedEmoji, +}) => { + const lang = useLang(); + + const handleCreateTopic = useCallback(() => { + }, []); + + return ( +
+
+ {animatedEmoji && } +
+

{lang('ChatList.EmptyTopicsTitle')}

+ +
+ ); +}; + +export default memo(withGlobal((global): StateProps => { + return { + animatedEmoji: selectAnimatedEmoji(global, '👀'), + }; +})(EmptyTopic)); diff --git a/src/components/left/main/ForumPanel.module.scss b/src/components/left/main/ForumPanel.module.scss new file mode 100644 index 000000000..173bc81dc --- /dev/null +++ b/src/components/left/main/ForumPanel.module.scss @@ -0,0 +1,96 @@ +.root { + position: absolute; + top: 0; + right: 0; + left: 4.75rem; + z-index: var(--z-forum-panel); + height: 100%; + background-color: var(--color-background); + border-left: 1px solid var(--color-borders); + display: flex; + flex-direction: column; + + &.rtl { + left: 0; + right: 4.75rem; + transform: translateX(-100%); + border-left: none; + border-right: 1px solid var(--color-borders); + } + + transition: transform var(--slide-transition); + transform: translate3d(100%, 0, 0); + + :global(.chat-list) { + position: relative; + overflow-x: hidden; + padding-top: 0 !important; + } + + :global(.HeaderActions) { + margin-left: auto; + display: flex; + align-items: center; + justify-content: flex-end; + } +} + +.group-call { + position: static !important; +} + +.border-bottom { + width: 100%; + height: 0; + transition: 0.1s ease-out border-color; + border-bottom: 0.0625rem solid transparent; +} + +.scrolled .border-bottom { + border-color: var(--color-borders); +} + +.scroll-top-handler { + position: absolute; + top: 0; + z-index: 100; + width: 100%; + height: 1px; +} + +.info { + margin-left: 0.4375rem; + min-width: 0; + width: 100%; + cursor: pointer; + + :global(.info) { + display: flex; + flex-direction: column; + justify-content: center; + flex-grow: 1; + overflow: hidden; + } + + :global(.fullName) { + line-height: 1.375rem; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + unicode-bidi: plaintext; + font-size: 1rem !important; + font-weight: 500 !important; + margin: 0 !important; + } + + :global(.status) { + font-size: 0.875rem; + line-height: 1.125rem; + margin: 0; + color: var(--color-text-secondary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: inline-block; + } +} diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/ForumPanel.tsx new file mode 100644 index 000000000..52427e24b --- /dev/null +++ b/src/components/left/main/ForumPanel.tsx @@ -0,0 +1,256 @@ +import React, { + memo, useCallback, useEffect, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { FC } from '../../../lib/teact/teact'; +import type { ApiChat } from '../../../api/types'; +import { MAIN_THREAD_ID } from '../../../api/types'; + +import { + TOPICS_SLICE, TOPIC_HEIGHT_PX, TOPIC_LIST_SENSITIVE_AREA, +} from '../../../config'; +import { selectChat, selectCurrentMessageList } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { fastRaf } from '../../../util/schedulers'; +import { getOrderedTopics } from '../../../global/helpers'; +import captureEscKeyListener from '../../../util/captureEscKeyListener'; +import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners'; + +import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; +import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver'; +import useChatOrderDiff from './hooks/useChatOrderDiff'; +import useLang from '../../../hooks/useLang'; +import usePrevious from '../../../hooks/usePrevious'; +import useHistoryBack from '../../../hooks/useHistoryBack'; +import { dispatchHeavyAnimationEvent } from '../../../hooks/useHeavyAnimationCheck'; + +import GroupChatInfo from '../../common/GroupChatInfo'; +import Button from '../../ui/Button'; +import Topic from './Topic'; +import InfiniteScroll from '../../ui/InfiniteScroll'; +import Loading from '../../ui/Loading'; +import HeaderActions from '../../middle/HeaderActions'; +import GroupCallTopPane from '../../calls/group/GroupCallTopPane'; +import EmptyTopic from './EmptyTopic'; + +import styles from './ForumPanel.module.scss'; + +type OwnProps = { + isOpen?: boolean; + isHidden?: boolean; + onTopicSearch?: NoneToVoidFunction; + onCloseAnimationEnd?: VoidFunction; +}; + +type StateProps = { + chat?: ApiChat; + currentTopicId?: number; + lastSyncTime?: number; +}; + +const INTERSECTION_THROTTLE = 200; + +const ForumPanel: FC = ({ + chat, + currentTopicId, + isOpen, + isHidden, + lastSyncTime, + onTopicSearch, + onCloseAnimationEnd, +}) => { + const { + closeForumPanel, openChatWithInfo, loadTopics, + } = getActions(); + + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const scrollTopHandlerRef = useRef(null); + + useEffect(() => { + if (lastSyncTime && chat && !chat.topics) { + loadTopics({ chatId: chat.id }); + } + }, [chat, lastSyncTime, loadTopics]); + + const [isScrolled, setIsScrolled] = useState(false); + const lang = useLang(); + + const handleClose = useCallback(() => { + closeForumPanel(); + }, [closeForumPanel]); + + const handleToggleChatInfo = useCallback(() => { + if (!chat) return; + openChatWithInfo({ id: chat.id, shouldReplaceHistory: true }); + }, [chat, openChatWithInfo]); + + const { observe } = useIntersectionObserver({ + rootRef: containerRef, + throttleMs: INTERSECTION_THROTTLE, + }); + + useOnIntersect(scrollTopHandlerRef, observe, ({ isIntersecting }) => { + setIsScrolled(!isIntersecting); + }); + + const orderedIds = useMemo(() => { + return chat?.topics + ? getOrderedTopics(Object.values(chat.topics), chat.orderedPinnedTopicIds).map(({ id }) => id) + : []; + }, [chat]); + + const { orderDiffById, getAnimationType } = useChatOrderDiff(orderedIds); + + const [viewportIds, getMore] = useInfiniteScroll(() => { + if (!chat || !lastSyncTime) return; + loadTopics({ chatId: chat.id }); + }, orderedIds, !chat?.topicsCount || orderedIds.length >= chat.topicsCount, TOPICS_SLICE); + + const shouldRenderRef = useRef(false); + const isVisible = isOpen && !isHidden; + const prevIsVisible = usePrevious(isVisible); + + if (prevIsVisible !== isVisible) { + shouldRenderRef.current = false; + } + + useHistoryBack({ + isActive: isVisible, + onBack: handleClose, + }); + + useEffect(() => (isVisible ? captureEscKeyListener(handleClose) : undefined), [handleClose, isVisible]); + + useEffect(() => { + if (prevIsVisible !== isVisible) { + const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent(); + waitForTransitionEnd(ref.current!, () => { + dispatchHeavyAnimationStop(); + }); + + // For performance reasons, we delay animation of the topic list panel to the next animation frame + fastRaf(() => { + if (isVisible) { + shouldRenderRef.current = true; + ref.current!.style.transform = 'none'; + } else { + shouldRenderRef.current = false; + ref.current!.style.transform = ''; + } + }); + } + }, [isVisible, prevIsVisible]); + + function renderTopics() { + const viewportOffset = orderedIds!.indexOf(viewportIds![0]); + + return viewportIds?.map((id, i) => ( + + )); + } + + const isLoading = chat?.topics === undefined; + + return ( +
+
+ + + {chat && ( + + )} + + {chat + && ( + + )} +
+ + {chat && } + +
+ + } + > + {viewportIds?.length ? ( + renderTopics() + ) : !isLoading ? ( + + ) : ( + + )} + +
+ ); +}; + +export default memo(withGlobal( + (global, ownProps, detachWhenChanged): StateProps => { + const chatId = global.forumPanelChatId; + detachWhenChanged(chatId); + + const chat = chatId ? selectChat(global, chatId) : undefined; + const { + chatId: currentChatId, + threadId: currentThreadId, + } = selectCurrentMessageList(global) || {}; + + return { + chat, + lastSyncTime: global.lastSyncTime, + currentTopicId: chatId === currentChatId ? currentThreadId : undefined, + }; + }, +)(ForumPanel)); diff --git a/src/components/left/main/LeftMain.scss b/src/components/left/main/LeftMain.scss index 718869a32..99af64490 100644 --- a/src/components/left/main/LeftMain.scss +++ b/src/components/left/main/LeftMain.scss @@ -17,6 +17,14 @@ flex-direction: column; overflow: hidden; + transition: 0.25s ease-out transform; + + &--tabs-hidden { + transform: translateY(-3.125rem); + + height: calc(100% + 3.125rem); + } + .tabs-placeholder { height: 2.625rem; /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ diff --git a/src/components/left/main/LeftMain.tsx b/src/components/left/main/LeftMain.tsx index b257535d6..3c89154f5 100644 --- a/src/components/left/main/LeftMain.tsx +++ b/src/components/left/main/LeftMain.tsx @@ -11,6 +11,7 @@ import { IS_TOUCH_ENV } from '../../../util/environment'; import buildClassName from '../../../util/buildClassName'; import useShowTransition from '../../../hooks/useShowTransition'; import useLang from '../../../hooks/useLang'; +import useForumPanelRender from '../../../hooks/useForumPanelRender'; import Transition from '../../ui/Transition'; import LeftMainHeader from './LeftMainHeader'; @@ -19,6 +20,7 @@ import LeftSearch from '../search/LeftSearch.async'; import ContactList from './ContactList.async'; import NewChatButton from '../NewChatButton'; import Button from '../../ui/Button'; +import ForumPanel from './ForumPanel'; import './LeftMain.scss'; @@ -30,9 +32,12 @@ type OwnProps = { shouldSkipTransition?: boolean; foldersDispatch: FolderEditDispatch; isUpdateAvailable?: boolean; + isForumPanelOpen?: boolean; + isClosingSearch?: boolean; onSearchQuery: (query: string) => void; onContentChange: (content: LeftColumnContent) => void; onScreenSelect: (screen: SettingsScreens) => void; + onTopicSearch: NoneToVoidFunction; onReset: () => void; }; @@ -45,17 +50,23 @@ const LeftMain: FC = ({ content, searchQuery, searchDate, + isClosingSearch, contactsFilter, shouldSkipTransition, foldersDispatch, isUpdateAvailable, + isForumPanelOpen, onSearchQuery, onContentChange, onScreenSelect, onReset, + onTopicSearch, }) => { const [isNewChatButtonShown, setIsNewChatButtonShown] = useState(IS_TOUCH_ENV); + const { shouldRenderForumPanel, handleForumPanelAnimationEnd } = useForumPanelRender(isForumPanelOpen); + const isForumPanelVisible = isForumPanelOpen && content === LeftColumnContent.ChatList; + const { shouldRender: shouldRenderUpdateButton, transitionClassNames: updateButtonClassNames, @@ -137,6 +148,7 @@ const LeftMain: FC = ({ onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined} > = ({ onSelectArchived={handleSelectArchived} onReset={onReset} shouldSkipTransition={shouldSkipTransition} + isClosingSearch={isClosingSearch} /> = ({ {(isActive) => { switch (content) { case LeftColumnContent.ChatList: - return ; + return ( + + ); case LeftColumnContent.GlobalSearch: return ( = ({ {lang('lng_update_telegram')} )} + {shouldRenderForumPanel && ( + + )} void; onSelectSettings: () => void; @@ -77,9 +80,11 @@ const LEGACY_VERSION_URL = 'https://web.telegram.org/?legacy=1'; const WEBK_VERSION_URL = 'https://web.telegram.org/k/'; const LeftMainHeader: FC = ({ + shouldHideSearch, content, contactsFilter, onSearchQuery, + isClosingSearch, onSelectSettings, onSelectContacts, onSelectArchived, @@ -250,12 +255,26 @@ const LeftMainHeader: FC = ({ const versionString = IS_BETA ? `${APP_VERSION} Beta (${APP_REVISION})` : (DEBUG ? APP_REVISION : APP_VERSION); + // Disable dropdown menu RTL animation for resize + const { + shouldDisableDropdownMenuTransitionRef, + handleDropdownMenuTransitionEnd, + } = useLeftHeaderButtonRtlForumTransition(shouldHideSearch); + return (
+ {lang.isRtl &&
} = ({ > {lang('ArchivedChats')} {archivedUnreadChatsCount > 0 && ( -
{archivedUnreadChatsCount}
+
{archivedUnreadChatsCount}
)}
= ({ = ({ + topic, + isSelected, + chatId, + chat, + style, + lastMessage, + canScrollDown, + lastMessageOutgoingStatus, + observeIntersection, + canDelete, + actionTargetMessage, + actionTargetUserIds, + actionTargetChatId, + lastMessageSender, + animationType, + animationLevel, + orderDiff, + typingStatus, + draft, + wasTopicOpened, +}) => { + const { openChat, deleteTopic, focusLastMessage } = getActions(); + + const lang = useLang(); + + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); + const [shouldRenderDeleteModal, markRenderDeleteModal, unmarkRenderDeleteModal] = useFlag(); + + const { + isPinned, isClosed, + } = topic; + const isMuted = topic.isMuted || (topic.isMuted === undefined && chat.isMuted); + + const handleOpenDeleteModal = useCallback(() => { + markRenderDeleteModal(); + openDeleteModal(); + }, [markRenderDeleteModal, openDeleteModal]); + + const handleDelete = useCallback(() => { + deleteTopic({ chatId: chat.id, topicId: topic.id }); + }, [chat.id, deleteTopic, topic.id]); + + const { renderSubtitle, ref } = useChatListEntry({ + chat, + chatId, + lastMessage, + draft, + actionTargetMessage, + actionTargetUserIds, + actionTargetChatId, + lastMessageSender, + lastMessageTopic: topic, + observeIntersection, + isTopic: true, + typingStatus, + + animationType, + animationLevel, + orderDiff, + }); + + const handleOpenTopic = useCallback(() => { + openChat({ id: chatId, threadId: topic.id, shouldReplaceHistory: true }); + + if (canScrollDown) { + focusLastMessage(); + } + }, [openChat, chatId, topic.id, canScrollDown, focusLastMessage]); + + const contextActions = useTopicContextActions(topic, chat, wasTopicOpened, canDelete, handleOpenDeleteModal); + + return ( + +
+
+
+ +

{renderText(topic.title)}

+
+ {topic.isMuted && } +
+ {isClosed && ( + + )} + {lastMessage && ( + + )} +
+
+ {renderSubtitle()} + +
+
+ + {shouldRenderDeleteModal && ( + + )} + + ); +}; + +export default memo(withGlobal( + (global, { chatId, topic, isSelected }) => { + const chat = selectChat(global, chatId); + + const lastMessage = selectChatMessage(global, chatId, topic.lastMessageId)!; + const { senderId, replyToMessageId, isOutgoing } = lastMessage || {}; + const lastMessageSender = senderId + ? (selectUser(global, senderId) || selectChat(global, senderId)) : undefined; + const lastMessageAction = lastMessage ? getMessageAction(lastMessage) : undefined; + const actionTargetMessage = lastMessageAction && replyToMessageId + ? selectChatMessage(global, chatId, replyToMessageId) + : undefined; + const { targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId } = lastMessageAction || {}; + 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 { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; + + return { + chat, + lastMessage, + actionTargetUserIds, + actionTargetChatId, + actionTargetMessage, + lastMessageSender, + typingStatus, + canDelete: selectCanDeleteTopic(global, chatId, topic.id), + animationLevel: global.settings.byKey.animationLevel, + draft, + ...(isOutgoing && lastMessage && { + lastMessageOutgoingStatus: selectOutgoingStatus(global, lastMessage), + }), + canScrollDown: isSelected && chat?.id === currentChatId && currentThreadId === topic.id, + wasTopicOpened, + }; + }, +)(Topic)); diff --git a/src/components/left/main/hooks/useChatAnimationType.ts b/src/components/left/main/hooks/useChatAnimationType.ts index 8bb1b463d..033daf388 100644 --- a/src/components/left/main/hooks/useChatAnimationType.ts +++ b/src/components/left/main/hooks/useChatAnimationType.ts @@ -6,13 +6,13 @@ export enum ChatAnimationTypes { None, } -export function useChatAnimationType(orderDiffById: Record) { +export function useChatAnimationType(orderDiffById: Record) { return useMemo(() => { - const orderDiffs = Object.values(orderDiffById); + const orderDiffs = Object.values(orderDiffById) as T[]; const numberOfUp = orderDiffs.filter((diff) => diff < 0).length; const numberOfDown = orderDiffs.filter((diff) => diff > 0).length; - return (chatId: string): ChatAnimationTypes => { + return (chatId: T): ChatAnimationTypes => { const orderDiff = orderDiffById[chatId]; if (orderDiff === 0) { return ChatAnimationTypes.None; diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx new file mode 100644 index 000000000..2895c3269 --- /dev/null +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -0,0 +1,224 @@ +import React, { useLayoutEffect, useMemo, useRef } from '../../../../lib/teact/teact'; +import { getGlobal } from '../../../../global'; + +import type { AnimationLevel } from '../../../../types'; +import type { LangFn } from '../../../../hooks/useLang'; +import type { + ApiChat, ApiTopic, ApiMessage, ApiTypingStatus, ApiUser, +} from '../../../../api/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; +import type { Thread } from '../../../../global/types'; + +import { ANIMATION_END_DELAY } from '../../../../config'; +import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; +import { + getMessageMediaHash, + getMessageMediaThumbDataUri, getMessageRoundVideo, + getMessageSenderName, getMessageSticker, getMessageVideo, isActionMessage, isChatChannel, +} from '../../../../global/helpers'; +import { renderActionMessageText } from '../../../common/helpers/renderActionMessageText'; +import renderText from '../../../common/helpers/renderText'; +import buildClassName from '../../../../util/buildClassName'; +import useLang from '../../../../hooks/useLang'; +import useEnsureMessage from '../../../../hooks/useEnsureMessage'; +import useMedia from '../../../../hooks/useMedia'; +import { ChatAnimationTypes } from './useChatAnimationType'; +import { fastRaf } from '../../../../util/schedulers'; + +import MessageSummary from '../../../common/MessageSummary'; +import ChatForumLastMessage from '../../../common/ChatForumLastMessage'; +import TypingStatus from '../../../common/TypingStatus'; + +const ANIMATION_DURATION = 200; + +export default function useChatListEntry({ + chat, + lastMessage, + chatId, + typingStatus, + draft, + actionTargetMessage, + actionTargetUserIds, + lastMessageTopic, + lastMessageSender, + actionTargetChatId, + observeIntersection, + animationType, + orderDiff, + animationLevel, + isTopic, +}: { + chat?: ApiChat; + lastMessage?: ApiMessage; + chatId: string; + typingStatus?: ApiTypingStatus; + draft?: Thread['draft']; + actionTargetMessage?: ApiMessage; + actionTargetUserIds?: string[]; + lastMessageTopic?: ApiTopic; + lastMessageSender?: ApiUser | ApiChat; + actionTargetChatId?: string; + observeIntersection?: ObserveFn; + isTopic?: boolean; + + animationType: ChatAnimationTypes; + orderDiff: number; + animationLevel?: AnimationLevel; +}) { + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + const isAction = lastMessage && isActionMessage(lastMessage); + + useEnsureMessage(chatId, isAction ? lastMessage.replyToMessageId : undefined, actionTargetMessage); + + const mediaThumbnail = lastMessage && !getMessageSticker(lastMessage) + ? getMessageMediaThumbDataUri(lastMessage) + : undefined; + const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined); + const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); + + const actionTargetUsers = useMemo(() => { + if (!actionTargetUserIds) { + return undefined; + } + + // No need for expensive global updates on users, so we avoid them + const usersById = getGlobal().users.byId; + return actionTargetUserIds.map((userId) => usersById[userId]).filter(Boolean); + }, [actionTargetUserIds]); + + function renderSubtitle() { + if (chat?.isForum && !isTopic) { + return ( + + ); + } + + return renderLastMessageOrTyping(); + } + + function renderLastMessageOrTyping() { + if (typingStatus && lastMessage && typingStatus.timestamp > lastMessage.date * 1000) { + return ; + } + + if (draft?.text.length) { + return ( +

+ {lang('Draft')} + {renderTextWithEntities(draft.text, draft.entities, undefined, undefined, undefined, undefined, true)} +

+ ); + } + + if (!lastMessage) { + return undefined; + } + + if (isAction) { + const isChat = chat && (isChatChannel(chat) || lastMessage.senderId === lastMessage.chatId); + + return ( +

+ {renderActionMessageText( + lang, + lastMessage, + !isChat ? lastMessageSender as ApiUser : undefined, + isChat ? chat : undefined, + actionTargetUsers, + actionTargetMessage, + actionTargetChatId, + lastMessageTopic, + { isEmbedded: true }, + )} +

+ ); + } + + const senderName = getMessageSenderName(lang, chatId, lastMessageSender); + + return ( +

+ {senderName && ( + <> + {renderText(senderName)} + : + + )} + {renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)} +

+ ); + } + + // Sets animation excess values when `orderDiff` changes and then resets excess values to animate + useLayoutEffect(() => { + const element = ref.current; + + if (animationLevel === 0 || !element) { + return; + } + + // TODO Refactor animation: create `useListAnimation` that owns `orderDiff` and `animationType` + if (animationType === ChatAnimationTypes.Opacity) { + element.style.opacity = '0'; + + fastRaf(() => { + element.classList.add('animate-opacity'); + element.style.opacity = '1'; + }); + } else if (animationType === ChatAnimationTypes.Move) { + element.style.transform = `translate3d(0, ${-orderDiff * 100}%, 0)`; + + fastRaf(() => { + element.classList.add('animate-transform'); + element.style.transform = ''; + }); + } else { + return; + } + + setTimeout(() => { + fastRaf(() => { + element.classList.remove('animate-opacity', 'animate-transform'); + element.style.opacity = ''; + element.style.transform = ''; + }); + }, ANIMATION_DURATION + ANIMATION_END_DELAY); + }, [animationLevel, orderDiff, animationType]); + + return { + renderSubtitle, + ref, + }; +} + +function renderSummary( + lang: LangFn, message: ApiMessage, observeIntersection?: ObserveFn, blobUrl?: string, isRoundVideo?: boolean, +) { + const messageSummary = ( + + ); + + if (!blobUrl) { + return messageSummary; + } + + return ( + + + {getMessageVideo(message) && } + {messageSummary} + + ); +} diff --git a/src/components/left/main/hooks/useChatOrderDiff.ts b/src/components/left/main/hooks/useChatOrderDiff.ts new file mode 100644 index 000000000..3d8f7addb --- /dev/null +++ b/src/components/left/main/hooks/useChatOrderDiff.ts @@ -0,0 +1,36 @@ +import { useMemo } from '../../../../lib/teact/teact'; +import usePrevious from '../../../../hooks/usePrevious'; +import { mapValues } from '../../../../util/iteratees'; +import { useChatAnimationType } from './useChatAnimationType'; + +export default function useChatOrderDiff(orderedIds: (string | number)[] | undefined) { + const orderById = useMemo(() => { + if (!orderedIds) { + return undefined; + } + + return orderedIds.reduce((acc, id, i) => { + acc[id] = i; + return acc; + }, {} as Record); + }, [orderedIds]); + + const prevOrderById = usePrevious(orderById); + + const orderDiffById = useMemo(() => { + if (!orderById || !prevOrderById) { + return {}; + } + + return mapValues(orderById, (order, id) => { + return prevOrderById[id] !== undefined ? order - prevOrderById[id] : -Infinity; + }); + }, [orderById, prevOrderById]); + + const getAnimationType = useChatAnimationType(orderDiffById); + + return { + orderDiffById, + getAnimationType, + }; +} diff --git a/src/components/left/main/hooks/useLeftHeaderButtonRtlForumTransition.ts b/src/components/left/main/hooks/useLeftHeaderButtonRtlForumTransition.ts new file mode 100644 index 000000000..f57de4161 --- /dev/null +++ b/src/components/left/main/hooks/useLeftHeaderButtonRtlForumTransition.ts @@ -0,0 +1,20 @@ +import { useRef } from '../../../../lib/teact/teact'; +import usePrevious from '../../../../hooks/usePrevious'; +import useForceUpdate from '../../../../hooks/useForceUpdate'; + +export default function useLeftHeaderButtonRtlForumTransition(shouldHideSearch?: boolean) { + const forceUpdate = useForceUpdate(); + const shouldDisableDropdownMenuTransitionRef = useRef(shouldHideSearch); + const prevShouldHideSearch = usePrevious(shouldHideSearch); + + function handleDropdownMenuTransitionEnd() { + shouldDisableDropdownMenuTransitionRef.current = Boolean(shouldHideSearch); + forceUpdate(); + } + + if (shouldHideSearch === false && prevShouldHideSearch !== shouldHideSearch) { + shouldDisableDropdownMenuTransitionRef.current = false; + } + + return { shouldDisableDropdownMenuTransitionRef, handleDropdownMenuTransitionEnd }; +} diff --git a/src/components/left/main/hooks/useTopicContextActions.ts b/src/components/left/main/hooks/useTopicContextActions.ts new file mode 100644 index 000000000..8feadd5f4 --- /dev/null +++ b/src/components/left/main/hooks/useTopicContextActions.ts @@ -0,0 +1,98 @@ +import { getActions } from '../../../../global'; + +import type { ApiChat, ApiTopic } from '../../../../api/types'; + +import { compact } from '../../../../util/iteratees'; +import { getHasAdminRight } from '../../../../global/helpers'; + +import useLang from '../../../../hooks/useLang'; +import { useMemo } from '../../../../lib/teact/teact'; + +export default function useTopicContextActions( + topic: ApiTopic, + chat: ApiChat, + wasOpened?: boolean, + canDelete?: boolean, + handleDelete?: NoneToVoidFunction, +) { + const lang = useLang(); + + return useMemo(() => { + const { + isPinned, isMuted, isClosed, isOwner, id: topicId, + } = topic; + + const chatId = chat.id; + + const { + editTopic, + toggleTopicPinned, + markTopicRead, + updateTopicMutedState, + } = getActions(); + + const canToggleClosed = isOwner || chat.isCreator || getHasAdminRight(chat, 'manageTopics'); + const canTogglePinned = chat.isCreator || getHasAdminRight(chat, 'manageTopics'); + + const actionUnreadMark = topic.unreadCount || !wasOpened + ? { + title: lang('MarkAsRead'), + icon: 'readchats', + handler: () => { + markTopicRead({ chatId, topicId }); + }, + } + : undefined; + + const actionPin = canTogglePinned ? (isPinned + ? { + title: lang('UnpinFromTop'), + icon: 'unpin', + handler: () => toggleTopicPinned({ chatId, topicId, isPinned: false }), + } + : { + title: lang('PinToTop'), + icon: 'pin', + handler: () => toggleTopicPinned({ chatId, topicId, isPinned: true }), + }) : undefined; + + const actionMute = ((chat.isMuted && isMuted !== false) || isMuted === true) + ? { + title: lang('ChatList.Unmute'), + icon: 'unmute', + handler: () => updateTopicMutedState({ chatId, topicId, isMuted: false }), + } + : { + title: lang('ChatList.Mute'), + icon: 'mute', + handler: () => updateTopicMutedState({ chatId, topicId, isMuted: true }), + }; + + const actionCloseTopic = canToggleClosed ? (isClosed + ? { + title: lang('lng_forum_topic_reopen'), + icon: 'reopen-topic', + handler: () => editTopic({ chatId, topicId, isClosed: false }), + } + : { + title: lang('lng_forum_topic_close'), + icon: 'close-topic', + handler: () => editTopic({ chatId, topicId, isClosed: true }), + }) : undefined; + + const actionDelete = canDelete ? { + title: lang('lng_forum_topic_delete'), + icon: 'delete', + destructive: true, + handler: handleDelete, + } : undefined; + + return compact([ + actionPin, + actionUnreadMark, + actionMute, + actionCloseTopic, + actionDelete, + ]); + }, [topic, chat, wasOpened, lang, canDelete, handleDelete]); +} diff --git a/src/components/left/search/ChatMessageResults.tsx b/src/components/left/search/ChatMessageResults.tsx index 895f7fa69..bfda6f85c 100644 --- a/src/components/left/search/ChatMessageResults.tsx +++ b/src/components/left/search/ChatMessageResults.tsx @@ -7,13 +7,15 @@ import { LoadMoreDirection } from '../../../types'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { throttle } from '../../../util/schedulers'; -import useLang from '../../../hooks/useLang'; import { renderMessageSummary } from '../../common/helpers/renderMessageText'; +import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; +import useLang from '../../../hooks/useLang'; import InfiniteScroll from '../../ui/InfiniteScroll'; import NothingFound from '../../common/NothingFound'; import ChatMessage from './ChatMessage'; import DateSuggest from './DateSuggest'; +import LeftSearchResultTopic from './LeftSearchResultTopic'; export type OwnProps = { searchQuery?: string; @@ -28,6 +30,8 @@ type StateProps = { globalMessagesByChatId?: Record }>; chatsById: Record; fetchingStatus?: { chats?: boolean; messages?: boolean }; + foundTopicIds?: number[]; + searchChatId?: string; lastSyncTime?: number; }; @@ -42,9 +46,12 @@ const ChatMessageResults: FC = ({ chatsById, fetchingStatus, lastSyncTime, + foundTopicIds, + searchChatId, onSearchDateSelect, + onReset, }) => { - const { searchMessagesGlobal } = getActions(); + const { searchMessagesGlobal, openChat } = getActions(); const lang = useLang(); const handleLoadMore = useCallback(({ direction }: { direction: LoadMoreDirection }) => { @@ -59,6 +66,17 @@ const ChatMessageResults: FC = ({ } }, [currentUserId, lastSyncTime, searchMessagesGlobal, searchQuery]); + const handleTopicClick = useCallback( + (id: number) => { + openChat({ id: searchChatId, threadId: id, shouldReplaceHistory: true }); + + if (!IS_SINGLE_COLUMN_LAYOUT) { + onReset(); + } + }, + [openChat, searchChatId, onReset], + ); + const foundMessages = useMemo(() => { if (!foundIds || foundIds.length === 0) { return MEMO_EMPTY_ARRAY; @@ -91,7 +109,8 @@ const ChatMessageResults: FC = ({ ); } - const nothingFound = fetchingStatus && !fetchingStatus.chats && !fetchingStatus.messages && !foundMessages.length; + const nothingFound = fetchingStatus && !fetchingStatus.chats && !fetchingStatus.messages && !foundMessages.length + && !foundTopicIds?.length; return (
@@ -115,7 +134,30 @@ const ChatMessageResults: FC = ({ description={lang('ChatList.Search.NoResultsDescription')} /> )} - {foundMessages.map(renderFoundMessage)} + {Boolean(foundTopicIds?.length) && ( +
+

+ {lang('Topics')} +

+ {foundTopicIds!.map((id) => { + return ( + + ); + })} +
+ )} + {Boolean(foundMessages.length) && ( +
+

+ {lang('SearchMessages')} +

+ {foundMessages.map(renderFoundMessage)} +
+ )}
); @@ -125,7 +167,9 @@ export default memo(withGlobal( (global): StateProps => { const { byId: chatsById } = global.chats; const { currentUserId, messages: { byChatId: globalMessagesByChatId }, lastSyncTime } = global; - const { fetchingStatus, resultsByType } = global.globalSearch; + const { + fetchingStatus, resultsByType, foundTopicIds, chatId: searchChatId, + } = global.globalSearch; const { foundIds } = (resultsByType?.text) || {}; @@ -135,7 +179,9 @@ export default memo(withGlobal( globalMessagesByChatId, chatsById, fetchingStatus, + foundTopicIds, lastSyncTime, + searchChatId, }; }, )(ChatMessageResults)); diff --git a/src/components/left/search/LeftSearch.scss b/src/components/left/search/LeftSearch.scss index 561fd1e9c..520214642 100644 --- a/src/components/left/search/LeftSearch.scss +++ b/src/components/left/search/LeftSearch.scss @@ -54,12 +54,18 @@ right: 0.625rem; } } + + &.topic-search-heading { + margin-left: -1.0625rem !important; + padding-left: 2.125rem; + } } .LeftSearch .search-section .section-heading, .RecentContacts .search-section .section-heading { - margin-left: -1rem !important; - width: calc(100% + 2rem); + margin-left: -0.5rem !important; + padding-left: 1.5rem; + width: calc(100% + 0.625rem); box-shadow: 0 -1px 0 0 var(--color-borders); &::before { @@ -135,6 +141,28 @@ } } } + + .topic-item { + display: flex; + align-items: center; + font-size: 1rem; + line-height: 1.6875rem; + font-weight: 500; + overflow: hidden; + + .topic-icon { + --custom-emoji-size: 2rem; + margin-inline-end: 0.25rem !important; + width: 2rem; + height: 2rem; + font-size: 2rem !important; + } + + .fullName { + overflow: hidden; + text-overflow: ellipsis; + } + } } .ListItem.search-result-message { @@ -150,12 +178,12 @@ @media (max-width: 600px) { .ListItem { - margin: 0 -0.625rem; + margin: 0 -0.125rem 0 -0.5rem; } } .search-section { - padding: 0 1rem 0.5rem; + padding: 0 0.125rem 0.5rem 0.5rem; .section-heading { color: var(--color-text-secondary); diff --git a/src/components/left/search/LeftSearchResultTopic.tsx b/src/components/left/search/LeftSearchResultTopic.tsx new file mode 100644 index 000000000..bbc554e00 --- /dev/null +++ b/src/components/left/search/LeftSearchResultTopic.tsx @@ -0,0 +1,71 @@ +import type { FC } from '../../../lib/teact/teact'; +import React, { memo, useCallback } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; + +import type { ApiTopic } from '../../../api/types'; + +import { + selectChat, +} from '../../../global/selectors'; +import { REM } from '../../common/helpers/mediaDimensions'; +import renderText from '../../common/helpers/renderText'; +import useSelectWithEnter from '../../../hooks/useSelectWithEnter'; + +import ListItem from '../../ui/ListItem'; +import TopicIcon from '../../common/TopicIcon'; + +type OwnProps = { + chatId: string; + topicId: number; + onClick: (id: number) => void; +}; + +type StateProps = { + topic?: ApiTopic; +}; + +const TOPIC_ICON_SIZE = 2 * REM; + +const LeftSearchResultTopic: FC = ({ + topicId, + topic, + onClick, +}) => { + const handleClick = useCallback(() => { + onClick(topicId); + }, [topicId, onClick]); + + const buttonRef = useSelectWithEnter(handleClick); + + if (!topic) { + return undefined; + } + + return ( + + +
{renderText(topic.title)}
+
+ ); +}; + +export default memo(withGlobal( + (global, { chatId, topicId }): StateProps => { + const chat = selectChat(global, chatId); + const topic = chat?.topics?.[topicId]; + + return { + topic, + }; + }, +)(LeftSearchResultTopic)); diff --git a/src/components/left/search/RecentContacts.scss b/src/components/left/search/RecentContacts.scss index 53777fd13..224df0f50 100644 --- a/src/components/left/search/RecentContacts.scss +++ b/src/components/left/search/RecentContacts.scss @@ -38,7 +38,11 @@ overflow: hidden; &:first-child { - margin-left: 0.125rem; + margin-left: 0.5rem; + + @media (max-width: 600px) { + margin-left: 0.125rem; + } } &:last-child { diff --git a/src/components/main/DraftRecipientPicker.tsx b/src/components/main/DraftRecipientPicker.tsx index 604154aee..dc6657fd7 100644 --- a/src/components/main/DraftRecipientPicker.tsx +++ b/src/components/main/DraftRecipientPicker.tsx @@ -33,8 +33,13 @@ const DraftRecipientPicker: FC = ({ } }, [isOpen, markIsShown]); - const handleSelectRecipient = useCallback((recipientId: string) => { - openChatWithDraft({ chatId: recipientId, text: requestedDraft!.text, files: requestedDraft!.files }); + const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => { + openChatWithDraft({ + chatId: recipientId, + threadId, + text: requestedDraft!.text, + files: requestedDraft!.files, + }); }, [openChatWithDraft, requestedDraft]); const handleClose = useCallback(() => { diff --git a/src/components/main/ForwardRecipientPicker.tsx b/src/components/main/ForwardRecipientPicker.tsx index 4fee6d4f8..99dce382d 100644 --- a/src/components/main/ForwardRecipientPicker.tsx +++ b/src/components/main/ForwardRecipientPicker.tsx @@ -24,7 +24,7 @@ const ForwardRecipientPicker: FC = ({ isManyMessages, }) => { const { - setForwardChatId, + setForwardChatOrTopic, exitForwardMode, forwardToSavedMessages, showNotification, @@ -39,7 +39,7 @@ const ForwardRecipientPicker: FC = ({ } }, [isOpen, markIsShown]); - const handleSelectRecipient = useCallback((recipientId: string) => { + const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => { if (recipientId === currentUserId) { forwardToSavedMessages(); showNotification({ @@ -48,9 +48,9 @@ const ForwardRecipientPicker: FC = ({ : 'Conversation.ForwardTooltip.SavedMessages.One'), }); } else { - setForwardChatId({ id: recipientId }); + setForwardChatOrTopic({ chatId: recipientId, topicId: threadId }); } - }, [currentUserId, forwardToSavedMessages, isManyMessages, lang, setForwardChatId, showNotification]); + }, [currentUserId, forwardToSavedMessages, isManyMessages, lang, setForwardChatOrTopic, showNotification]); const handleClose = useCallback(() => { exitForwardMode(); diff --git a/src/components/main/Main.scss b/src/components/main/Main.scss index 51c44d9ec..92e218a1e 100644 --- a/src/components/main/Main.scss +++ b/src/components/main/Main.scss @@ -28,9 +28,14 @@ } #LeftColumn { - min-width: 12rem; width: 33vw; - max-width: 26.5rem; + + --left-column-min-width: 16rem; + --left-column-max-width: 26.5rem; + + min-width: var(--left-column-min-width); + max-width: var(--left-column-max-width); + height: 100%; position: relative; background-color: var(--color-background); @@ -45,12 +50,12 @@ } @media (min-width: 926px) { - max-width: 40vw; + --left-column-max-width: 40vw; } @media (min-width: 1276px) { width: 25vw; - max-width: 33vw; + --left-column-max-width: 33vw; } @media (max-width: 925px) { @@ -115,6 +120,7 @@ @media (max-width: 600px) { max-width: none; + --left-column-max-width: calc(100vw - env(safe-area-inset-left)); transform: translate3d(-20vw, 0, 0); left: env(safe-area-inset-left) !important; width: calc(100vw - env(safe-area-inset-left)) !important; diff --git a/src/components/main/WebAppModal.tsx b/src/components/main/WebAppModal.tsx index 3522373f5..7d3f617a8 100644 --- a/src/components/main/WebAppModal.tsx +++ b/src/components/main/WebAppModal.tsx @@ -108,7 +108,7 @@ const WebAppModal: FC = ({ const lang = useLang(); const { - url, buttonText, queryId, + url, buttonText, queryId, replyToMessageId, threadId, } = webApp || {}; const isOpen = Boolean(url); const isSimple = !queryId; @@ -204,6 +204,8 @@ const WebAppModal: FC = ({ botId: bot!.id, queryId: queryId!, peerId: chat!.id, + replyToMessageId, + threadId, }); }, queryId ? PROLONG_INTERVAL : undefined, true); diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 8d69a81e8..6d4341797 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -22,7 +22,7 @@ import { selectListedIds, selectOutlyingIds, selectScheduledMessage, - selectScheduledMessages, + selectChatScheduledMessages, selectUser, } from '../../global/selectors'; import { stopCurrentAudio } from '../../util/audioPlayer'; @@ -463,7 +463,7 @@ export default memo(withGlobal( let chatMessages: Record | undefined; if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) { - chatMessages = selectScheduledMessages(global, chatId); + chatMessages = selectChatScheduledMessages(global, chatId); } else { chatMessages = selectChatMessages(global, chatId); } diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index 536be58f4..f9e7602fd 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -5,7 +5,7 @@ import React, { import { getActions, withGlobal } from '../../global'; import type { - ApiUser, ApiMessage, ApiChat, ApiSticker, + ApiUser, ApiMessage, ApiChat, ApiSticker, ApiTopic, } from '../../api/types'; import type { FocusDirection } from '../../types'; @@ -14,6 +14,7 @@ import { selectChatMessage, selectIsMessageFocused, selectChat, + selectTopicFromMessage, } from '../../global/selectors'; import { getMessageHtmlId, isChatChannel } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; @@ -39,6 +40,7 @@ type OwnProps = { isEmbedded?: boolean; appearanceOrder?: number; isLastInList?: boolean; + isInsideTopic?: boolean; memoFirstUnreadIdRef?: { current: number | undefined }; }; @@ -50,6 +52,7 @@ type StateProps = { targetMessage?: ApiMessage; targetChatId?: string; isFocused: boolean; + topic?: ApiTopic; focusDirection?: FocusDirection; noFocusHighlight?: boolean; premiumGiftSticker?: ApiSticker; @@ -59,9 +62,6 @@ const APPEARANCE_DELAY = 10; const ActionMessage: FC = ({ message, - observeIntersectionForReading, - observeIntersectionForLoading, - observeIntersectionForPlaying, isEmbedded, appearanceOrder = 0, isLastInList, @@ -75,7 +75,12 @@ const ActionMessage: FC = ({ focusDirection, noFocusHighlight, premiumGiftSticker, + isInsideTopic, + topic, memoFirstUnreadIdRef, + observeIntersectionForReading, + observeIntersectionForLoading, + observeIntersectionForPlaying, }) => { const { openPremiumModal, requestConfetti } = getActions(); @@ -130,6 +135,7 @@ const ActionMessage: FC = ({ targetUsers, targetMessage, targetChatId, + topic, { isEmbedded }, observeIntersectionForLoading, observeIntersectionForPlaying, @@ -155,6 +161,12 @@ const ActionMessage: FC = ({ }); }; + // TODO: Refactoring for action rendering + const shouldSkipRender = isInsideTopic && message.content.action?.text === 'TopicWasCreatedAction'; + if (shouldSkipRender) { + return ; + } + if (isEmbedded) { return {content}; } @@ -229,6 +241,7 @@ export default memo(withGlobal( const senderUser = !isChat && userId ? selectUser(global, userId) : undefined; const senderChat = isChat ? chat : undefined; const premiumGiftSticker = global.premiumGifts?.stickers?.[0]; + const topic = selectTopicFromMessage(global, message); return { usersById, @@ -239,6 +252,7 @@ export default memo(withGlobal( targetMessage, isFocused, premiumGiftSticker, + topic, ...(isFocused && { focusDirection, noFocusHighlight }), }; }, diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index 1ec70a77e..70f3c7ab7 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -39,6 +39,8 @@ interface OwnProps { threadId: number; messageListType: MessageListType; canExpandActions: boolean; + withForumActions?: boolean; + onTopicSearch?: NoneToVoidFunction; } interface StateProps { @@ -81,10 +83,12 @@ const HeaderActions: FC = ({ canCreateVoiceChat, pendingJoinRequests, isRightColumnShown, + withForumActions, canExpandActions, shouldJoinToSend, shouldSendJoinRequest, noAnimation, + onTopicSearch, }) => { const { joinChannel, @@ -94,6 +98,7 @@ const HeaderActions: FC = ({ requestCall, requestNextManagementScreen, showNotification, + openChat, } = getActions(); // eslint-disable-next-line no-null/no-null const menuButtonRef = useRef(null); @@ -137,6 +142,11 @@ const HeaderActions: FC = ({ }, [requestNextManagementScreen]); const handleSearchClick = useCallback(() => { + if (withForumActions) { + onTopicSearch?.(); + return; + } + openLocalTextSearch(); if (IS_SINGLE_COLUMN_LAYOUT) { @@ -151,7 +161,11 @@ const HeaderActions: FC = ({ } else { setTimeout(setFocusInSearchInput, SEARCH_FOCUS_DELAY_MS); } - }, [noAnimation, openLocalTextSearch]); + }, [noAnimation, onTopicSearch, openLocalTextSearch, withForumActions]); + + const handleAsMessagesClick = useCallback(() => { + openChat({ id: chatId, threadId: MAIN_THREAD_ID }); + }, [chatId, openChat]); function handleRequestCall() { requestCall({ userId: chatId }); @@ -240,7 +254,7 @@ const HeaderActions: FC = ({ )} )} - {Boolean(pendingJoinRequests) && ( + {!withForumActions && Boolean(pendingJoinRequests) && ( )} - {canManage && ( + {canManage && !isInsideTopic && (
{!isFormFullyDisabled && ( diff --git a/src/components/right/management/Management.scss b/src/components/right/management/Management.scss index 7dc414aed..93234f55b 100644 --- a/src/components/right/management/Management.scss +++ b/src/components/right/management/Management.scss @@ -106,6 +106,10 @@ font-size: 0.875rem; } + .section-info_push { + margin-top: 0.5rem; + } + &[dir="rtl"] { text-align: right; } diff --git a/src/components/ui/AvatarEditable.scss b/src/components/ui/AvatarEditable.scss index b1cb7bf5b..e9d7fb787 100644 --- a/src/components/ui/AvatarEditable.scss +++ b/src/components/ui/AvatarEditable.scss @@ -18,6 +18,7 @@ position: relative; overflow: hidden; outline: none !important; + transition: border-radius 200ms; input { display: none; @@ -82,5 +83,9 @@ display: none; } } + + &.rounded-square { + border-radius: var(--border-radius-forum-avatar); + } } } diff --git a/src/components/ui/AvatarEditable.tsx b/src/components/ui/AvatarEditable.tsx index 7547bfd22..bdff3f2e7 100644 --- a/src/components/ui/AvatarEditable.tsx +++ b/src/components/ui/AvatarEditable.tsx @@ -13,6 +13,7 @@ import './AvatarEditable.scss'; interface OwnProps { title?: string; disabled?: boolean; + isForForum?: boolean; currentAvatarBlobUrl?: string; onChange: (file: File) => void; } @@ -20,6 +21,7 @@ interface OwnProps { const AvatarEditable: FC = ({ title = 'Change your profile picture', disabled, + isForForum, currentAvatarBlobUrl, onChange, }) => { @@ -58,6 +60,7 @@ const AvatarEditable: FC = ({ const labelClassName = buildClassName( croppedBlobUrl && 'filled', disabled && 'disabled', + isForForum && 'rounded-square', ); return ( diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 2cb3d207f..531d2d7c7 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -49,6 +49,7 @@ export type OwnProps = { onMouseEnter?: NoneToVoidFunction; onMouseLeave?: NoneToVoidFunction; onFocus?: NoneToVoidFunction; + onTransitionEnd?: NoneToVoidFunction; }; // Longest animation duration; @@ -76,6 +77,7 @@ const Button: FC = ({ isLoading, isShiny, withPremiumGradient, + onTransitionEnd, ariaLabel, ariaControls, hasPopup, @@ -154,6 +156,7 @@ const Button: FC = ({ aria-label={ariaLabel} aria-controls={ariaControls} style={style} + onTransitionEnd={onTransitionEnd} > {children} {!disabled && ripple && ( @@ -174,6 +177,7 @@ const Button: FC = ({ onMouseDown={handleMouseDown} onMouseEnter={onMouseEnter && !disabled ? onMouseEnter : undefined} onMouseLeave={onMouseLeave && !disabled ? onMouseLeave : undefined} + onTransitionEnd={onTransitionEnd} onFocus={onFocus && !disabled ? onFocus : undefined} aria-label={ariaLabel} aria-controls={ariaControls} diff --git a/src/components/ui/DropdownMenu.tsx b/src/components/ui/DropdownMenu.tsx index 892055d40..6f1e0fa96 100644 --- a/src/components/ui/DropdownMenu.tsx +++ b/src/components/ui/DropdownMenu.tsx @@ -15,6 +15,7 @@ type OwnProps = { onOpen?: NoneToVoidFunction; onClose?: NoneToVoidFunction; onHide?: NoneToVoidFunction; + onTransitionEnd?: NoneToVoidFunction; children: React.ReactNode; }; @@ -28,6 +29,7 @@ const DropdownMenu: FC = ({ forceOpen, onOpen, onClose, + onTransitionEnd, onHide, }) => { // eslint-disable-next-line no-null/no-null @@ -70,6 +72,7 @@ const DropdownMenu: FC = ({ ref={dropdownRef} className={`DropdownMenu ${className || ''}`} onKeyDown={handleKeyDown} + onTransitionEnd={onTransitionEnd} > {trigger({ onTrigger: toggleIsOpen, isOpen })} diff --git a/src/components/ui/InfiniteScroll.tsx b/src/components/ui/InfiniteScroll.tsx index 996e86a1e..341b4bc83 100644 --- a/src/components/ui/InfiniteScroll.tsx +++ b/src/components/ui/InfiniteScroll.tsx @@ -24,6 +24,7 @@ type OwnProps = { noScrollRestoreOnTop?: boolean; noFastList?: boolean; cacheBuster?: any; + beforeChildren?: React.ReactNode; children: React.ReactNode; onLoadMore?: ({ direction }: { direction: LoadMoreDirection; noScroll?: boolean }) => void; onScroll?: (e: UIEvent) => void; @@ -51,6 +52,7 @@ const InfiniteScroll: FC = ({ noFastList, // Used to re-query `listItemElements` if rendering is delayed by transition cacheBuster, + beforeChildren, children, onLoadMore, onScroll, @@ -230,6 +232,7 @@ const InfiniteScroll: FC = ({ onDragOver={onDragOver} onDragLeave={onDragLeave} > + {beforeChildren} {withAbsolutePositioning && items?.length ? (
.Switcher { + margin-left: auto; + } } .user-status, diff --git a/src/components/ui/ListItem.tsx b/src/components/ui/ListItem.tsx index 478d08347..9b200559a 100644 --- a/src/components/ui/ListItem.tsx +++ b/src/components/ui/ListItem.tsx @@ -44,11 +44,13 @@ interface OwnProps { destructive?: boolean; multiline?: boolean; isStatic?: boolean; + clickArg?: any; contextActions?: MenuItemContextAction[]; onMouseDown?: (e: React.MouseEvent) => void; - onClick?: (e: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent, arg?: any) => void; onSecondaryIconClick?: (e: React.MouseEvent) => void; onDragEnter?: (e: React.DragEvent) => void; + shouldUsePortalForMenu?: boolean; } const ListItem: FC = ({ @@ -74,8 +76,10 @@ const ListItem: FC = ({ contextActions, onMouseDown, onClick, + clickArg, onSecondaryIconClick, onDragEnter, + shouldUsePortalForMenu, }) => { // eslint-disable-next-line no-null/no-null let containerRef = useRef(null); @@ -102,6 +106,11 @@ const ListItem: FC = ({ [], ); + const getLayout = useCallback( + () => ({ shouldUsePortalPositioning: shouldUsePortalForMenu }), + [shouldUsePortalForMenu], + ); + const { positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, } = useContextMenuPosition( @@ -109,19 +118,20 @@ const ListItem: FC = ({ getTriggerElement, getRootElement, getMenuElement, + getLayout, ); const handleClick = useCallback((e: React.MouseEvent) => { if ((disabled && !allowDisabledClick) || !onClick) { return; } - onClick(e); + onClick(e, clickArg); if (IS_TOUCH_ENV && !ripple) { markIsTouched(); fastRaf(unmarkIsTouched); } - }, [allowDisabledClick, disabled, markIsTouched, onClick, ripple, unmarkIsTouched]); + }, [allowDisabledClick, clickArg, disabled, markIsTouched, onClick, ripple, unmarkIsTouched]); const handleSecondaryIconClick = (e: React.MouseEvent) => { if ((disabled && !allowDisabledClick) || e.button !== 0 || (!onSecondaryIconClick && !contextActions)) return; @@ -220,6 +230,7 @@ const ListItem: FC = ({ autoClose onClose={handleContextMenuClose} onCloseAnimationEnd={handleContextMenuHide} + shouldUsePortalForMenu={shouldUsePortalForMenu} > {contextActions.map((action) => ( void; onMouseEnter?: (e: React.MouseEvent) => void; onMouseLeave?: (e: React.MouseEvent) => void; + shouldUsePortalForMenu?: boolean; children: React.ReactNode; }; @@ -67,6 +70,7 @@ const Menu: FC = ({ onMouseEnter, onMouseLeave, shouldSkipTransition, + shouldUsePortalForMenu, }) => { // eslint-disable-next-line no-null/no-null let menuRef = useRef(null); @@ -121,7 +125,7 @@ const Menu: FC = ({ const transformOriginYStyle = transformOriginY !== undefined ? `${transformOriginY}px` : undefined; const transformOriginXStyle = transformOriginX !== undefined ? `${transformOriginX}px` : undefined; - return ( + const menu = (
= ({
); + + if (shouldUsePortalForMenu) { + return {menu}; + } + + return menu; }; export default Menu; diff --git a/src/components/ui/MenuItem.scss b/src/components/ui/MenuItem.scss index 8141a5b79..f26ef24af 100644 --- a/src/components/ui/MenuItem.scss +++ b/src/components/ui/MenuItem.scss @@ -16,6 +16,32 @@ cursor: pointer; unicode-bidi: plaintext; + .right-badge { + min-width: 1.5rem; + height: 1.5rem; + margin-left: auto; + background: var(--color-gray); + border-radius: 0.75rem; + padding: 0 0.4375rem; + color: white; + font-size: 0.875rem; + line-height: 1.5rem; + font-weight: 500; + text-align: center; + flex-shrink: 0; + } + + &.compact .right-badge { + background: none; + padding: 0; + color: var(--color-text-secondary); + } + + &[dir="rtl"] .right-badge { + margin-left: 0; + margin-right: auto; + } + @media (hover: hover) { &:hover, &:focus { diff --git a/src/components/ui/Tab.tsx b/src/components/ui/Tab.tsx index 3467441b6..35abb9351 100644 --- a/src/components/ui/Tab.tsx +++ b/src/components/ui/Tab.tsx @@ -51,6 +51,10 @@ const Tab: FC = ({ const tabEl = tabRef.current!; const prevTabEl = tabEl.parentElement!.children[previousActiveTab]; if (!prevTabEl) { + // The number of tabs in the parent component has decreased. It is necessary to add the active tab class name. + if (isActive && !tabEl.classList.contains(classNames.active)) { + tabEl.classList.add(classNames.active); + } return; } diff --git a/src/config.ts b/src/config.ts index 4f0b3d89f..74025e715 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,22 +59,28 @@ export const MESSAGE_LIST_SLICE = isBigScreen ? 60 : 40; export const MESSAGE_LIST_VIEWPORT_LIMIT = MESSAGE_LIST_SLICE * 2; export const CHAT_HEIGHT_PX = 72; +export const CHAT_HEIGHT_FORUM_PX = 96; +export const TOPIC_HEIGHT_PX = 65; export const CHAT_LIST_SLICE = isBigScreen ? 30 : 25; export const CHAT_LIST_LOAD_SLICE = 100; export const SHARED_MEDIA_SLICE = 42; export const MESSAGE_SEARCH_SLICE = 42; export const GLOBAL_SEARCH_SLICE = 20; +export const GLOBAL_TOPIC_SEARCH_SLICE = 5; export const MEMBERS_SLICE = 30; export const MEMBERS_LOAD_SLICE = 200; export const PINNED_MESSAGES_LIMIT = 50; export const BLOCKED_LIST_LIMIT = 100; export const PROFILE_PHOTOS_LIMIT = 40; export const PROFILE_SENSITIVE_AREA = 500; +export const TOPIC_LIST_SENSITIVE_AREA = 600; export const COMMON_CHATS_LIMIT = 100; export const GROUP_CALL_PARTICIPANTS_LIMIT = 100; export const REACTION_LIST_LIMIT = 100; export const REACTION_UNREAD_SLICE = 100; export const MENTION_UNREAD_SLICE = 100; +export const TOPICS_SLICE = 20; +export const TOPICS_SLICE_SECOND_LOAD = 500; export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20; @@ -232,6 +238,7 @@ export const DEFAULT_LANG_CODE = 'en'; export const DEFAULT_LANG_PACK = 'android'; export const LANG_PACKS = ['android', 'ios', 'tdesktop', 'macos'] as const; export const FEEDBACK_URL = 'https://bugs.telegram.org/?tag_ids=41&sort=time'; +export const GENERAL_TOPIC_ID = 1; export const LIGHT_THEME_BG_COLOR = '#99BA92'; export const DARK_THEME_BG_COLOR = '#0F0F0F'; diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index ba3856787..7ad751aa9 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -7,10 +7,11 @@ import type { } from '../../../api/types'; import type { InlineBotSettings } from '../../../types'; +import { MAIN_THREAD_ID } from '../../../api/types'; import { callApi } from '../../../api/gramjs'; import { selectChat, selectChatBot, selectChatMessage, selectCurrentChat, selectCurrentMessageList, - selectIsTrustedBot, selectReplyingToId, selectSendAs, selectUser, + selectIsTrustedBot, selectReplyingToId, selectSendAs, selectUser, selectThreadTopMessageId, } from '../../selectors'; import { addChats, addUsers, removeBlockedContact } from '../../reducers'; import { buildCollectionByKey } from '../../../util/iteratees'; @@ -306,6 +307,12 @@ addActionHandler('sendInlineBotResult', (global, actions, payload) => { const { chatId, threadId } = currentMessageList; const chat = selectChat(global, chatId)!; + const replyingTo = selectReplyingToId(global, chatId, threadId); + let replyingToTopId: number | undefined; + + if (replyingTo && threadId !== MAIN_THREAD_ID) { + replyingToTopId = selectThreadTopMessageId(global, chatId, threadId)!; + } actions.setReplyingToId({ messageId: undefined }); actions.clearWebPagePreview({ chatId, threadId, value: false }); @@ -314,7 +321,8 @@ addActionHandler('sendInlineBotResult', (global, actions, payload) => { chat, resultId: id, queryId, - replyingTo: selectReplyingToId(global, chatId, threadId), + replyingTo, + replyingToTopId, sendAs: selectSendAs(global, chatId), isSilent, scheduleDate: scheduledAt, @@ -438,6 +446,7 @@ addActionHandler('requestWebView', async (global, actions, payload) => { theme, isSilent, replyToMessageId: reply || undefined, + threadId, isFromBotMenu, startParam, sendAs, @@ -455,6 +464,8 @@ addActionHandler('requestWebView', async (global, actions, payload) => { url: webViewUrl, botId, queryId, + replyToMessageId: reply || undefined, + threadId, buttonText, }, }); @@ -462,7 +473,7 @@ addActionHandler('requestWebView', async (global, actions, payload) => { addActionHandler('prolongWebView', async (global, actions, payload) => { const { - botId, peerId, isSilent, replyToMessageId, queryId, + botId, peerId, isSilent, replyToMessageId, queryId, threadId, } = payload; const bot = selectUser(global, botId); @@ -477,6 +488,7 @@ addActionHandler('prolongWebView', async (global, actions, payload) => { peer, isSilent, replyToMessageId, + threadId, queryId, sendAs, }); @@ -579,7 +591,7 @@ async function loadAttachBots(hash?: string) { addActionHandler('callAttachBot', (global, actions, payload) => { const { - chatId, botId, isFromBotMenu, url, startParam, + chatId, botId, isFromBotMenu, url, startParam, threadId, } = payload; const { attachMenu: { bots } } = global; if (!isFromBotMenu && !bots[botId]) { @@ -589,13 +601,15 @@ addActionHandler('callAttachBot', (global, actions, payload) => { botId, onConfirm: { action: 'callAttachBot', - payload: { chatId, botId, startParam }, + payload: { + chatId, botId, startParam, threadId, + }, }, }, }; } const theme = extractCurrentThemeParams(); - actions.openChat({ id: chatId }); + actions.openChat({ id: chatId, threadId }); actions.requestWebView({ url, peerId: chatId, diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index f4a3ff5d4..cd914f680 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -17,20 +17,37 @@ import { SERVICE_NOTIFICATIONS_USER_ID, TMP_CHAT_ID, ALL_FOLDER_ID, - DEBUG, + DEBUG, TOPICS_SLICE, TOPICS_SLICE_SECOND_LOAD, } from '../../../config'; import { callApi } from '../../../api/gramjs'; import { - addChats, addUsers, addUserStatuses, replaceThreadParam, - updateChatListIds, updateChats, updateChat, updateChatListSecondaryInfo, - updateManagementProgress, leaveChat, replaceUsers, replaceUserStatuses, - replaceChats, replaceChatListIds, addChatMembers, updateUser, + addChats, + addUsers, + addUserStatuses, + replaceThreadParam, + updateChatListIds, + updateChats, + updateChat, + updateChatListSecondaryInfo, + updateManagementProgress, + leaveChat, + replaceUsers, + replaceUserStatuses, + replaceChats, + replaceChatListIds, + addChatMembers, + updateUser, + addMessages, + updateTopics, + deleteTopic, + updateTopic, + updateThreadInfo, } from '../../reducers'; import { selectChat, selectUser, selectChatListType, selectIsChatPinned, - selectChatFolder, selectSupportChat, selectChatByUsername, selectThreadTopMessageId, + selectChatFolder, selectSupportChat, selectChatByUsername, selectCurrentMessageList, selectThreadInfo, selectCurrentChat, selectLastServiceNotification, - selectVisibleUsers, selectUserByPhoneNumber, selectDraft, + selectVisibleUsers, selectUserByPhoneNumber, selectDraft, selectThreadTopMessageId, } from '../../selectors'; import { buildCollectionByKey, omit } from '../../../util/iteratees'; import { debounce, pause, throttle } from '../../../util/schedulers'; @@ -83,7 +100,7 @@ addActionHandler('preloadTopChatMessages', async (global, actions) => { }); addActionHandler('openChat', (global, actions, payload) => { - const { id, threadId = MAIN_THREAD_ID } = payload; + const { id, threadId = MAIN_THREAD_ID, noForumTopicPanel } = payload; if (!id) { return; } @@ -103,6 +120,10 @@ addActionHandler('openChat', (global, actions, payload) => { }); } + if (chat?.isForum && !noForumTopicPanel) { + actions.openForumPanel({ chatId: id }); + } + if (!chat) { if (id === currentUserId) { void callApi('fetchChat', { type: 'self' }); @@ -264,6 +285,20 @@ addActionHandler('updateChatMutedState', (global, actions, payload) => { void callApi('updateChatMutedState', { chat, isMuted, serverTimeOffset }); }); +addActionHandler('updateTopicMutedState', (global, actions, payload) => { + const { serverTimeOffset } = global; + const { chatId, isMuted, topicId } = payload; + const chat = selectChat(global, chatId); + if (!chat) { + return; + } + + setGlobal(updateTopic(global, chatId, topicId, { isMuted })); + void callApi('updateTopicMutedState', { + chat, topicId, isMuted, serverTimeOffset, + }); +}); + addActionHandler('createChannel', (global, actions, payload) => { const { title, about, photo, memberIds, @@ -555,6 +590,31 @@ addActionHandler('toggleChatUnread', (global, actions, payload) => { } }); +addActionHandler('markTopicRead', (global, actions, payload) => { + const { chatId, topicId } = payload; + const chat = selectChat(global, chatId); + if (!chat) return; + + const lastTopicMessageId = chat.topics?.[topicId]?.lastMessageId; + if (!lastTopicMessageId) return; + + void callApi('markMessageListRead', { + chat, + threadId: topicId, + maxId: lastTopicMessageId, + serverTimeOffset: global.serverTimeOffset, + }); + + global = getGlobal(); + global = updateTopic(global, chatId, topicId, { + unreadCount: 0, + }); + global = updateThreadInfo(global, chatId, topicId, { + lastReadInboxMessageId: lastTopicMessageId, + }); + setGlobal(global); +}); + addActionHandler('openChatByInvite', async (global, actions, payload) => { const { hash } = payload!; @@ -697,6 +757,7 @@ addActionHandler('openTelegramLink', (global, actions, payload) => { openChatByUsernameAction({ username: part1, messageId: messageId || Number(chatOrChannelPostId), + threadId: messageId ? Number(chatOrChannelPostId) : undefined, commentId, startParam: params.start, startAttach, @@ -717,17 +778,17 @@ addActionHandler('acceptInviteConfirmation', async (global, actions, payload) => addActionHandler('openChatByUsername', async (global, actions, payload) => { const { - username, messageId, commentId, startParam, startAttach, attach, + username, messageId, commentId, startParam, startAttach, attach, threadId, } = payload!; const chat = selectCurrentChat(global); if (!commentId) { if (!startAttach && !startParam && chat?.usernames?.some((c) => c.username === username)) { - actions.focusMessage({ chatId: chat.id, messageId }); + actions.focusMessage({ chatId: chat.id, threadId, messageId }); return; } - await openChatByUsername(actions, username, messageId, startParam, startAttach, attach); + await openChatByUsername(actions, username, threadId, messageId, startParam, startAttach, attach); return; } @@ -1003,7 +1064,7 @@ addActionHandler('loadGroupsForDiscussion', async (global) => { } const addedById = groups.reduce((result, group) => { - if (group) { + if (group && !group.isForum) { result[group.id] = group; } @@ -1051,6 +1112,7 @@ addActionHandler('linkDiscussionGroup', async (global, actions, payload) => { } if (fullInfo!.isPreHistoryHidden) { + global = getGlobal(); setGlobal(updateChat(global, chat.id, { fullInfo: { ...chat.fullInfo, @@ -1217,6 +1279,21 @@ addActionHandler('toggleJoinRequest', async (global, actions, payload) => { await callApi('toggleJoinRequest', chat, isEnabled); }); +addActionHandler('openForumPanel', (global, actions, payload) => { + const { chatId } = payload; + return { + ...global, + forumPanelChatId: chatId, + }; +}); + +addActionHandler('closeForumPanel', (global) => { + return { + ...global, + forumPanelChatId: undefined, + }; +}); + addActionHandler('processAttachBotParameters', async (global, actions, payload) => { const { username, filter, startParam } = payload; const bot = await getAttachBotOrNotify(global, username); @@ -1249,6 +1326,130 @@ addActionHandler('processAttachBotParameters', async (global, actions, payload) }); }); +addActionHandler('loadTopics', async (global, actions, payload) => { + const { chatId, force } = payload; + const chat = selectChat(global, chatId); + if (!chat) return; + + if (!force && chat.topics && Object.values(chat.topics).length === chat.topicsCount) { + return; + } + + const offsetTopic = !force && chat.topics ? Object.values(chat.topics).reduce((acc, el) => { + if (!acc || el.lastMessageId < acc.lastMessageId) { + return el; + } + return acc; + }) : undefined; + + const { id: offsetTopicId, date: offsetDate, lastMessageId: offsetId } = offsetTopic || {}; + const result = await callApi('fetchTopics', { + chat, offsetTopicId, offsetId, offsetDate, limit: offsetTopicId ? TOPICS_SLICE : TOPICS_SLICE_SECOND_LOAD, + }); + + if (!result) return; + + global = getGlobal(); + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + global = addChats(global, buildCollectionByKey(result.chats, 'id')); + global = addMessages(global, result.messages); + global = updateTopics(global, chatId, result.count, result.topics); + Object.entries(result.draftsById || {}).forEach(([threadId, draft]) => { + global = replaceThreadParam(global, chatId, Number(threadId), 'draft', draft?.formattedText); + global = replaceThreadParam(global, chatId, Number(threadId), 'replyingToId', draft?.replyingToId); + }); + Object.entries(result.readInboxMessageIdByTopicId || {}).forEach(([topicId, messageId]) => { + global = updateThreadInfo(global, chatId, Number(topicId), { lastReadInboxMessageId: messageId }); + }); + + setGlobal(global); +}); + +addActionHandler('loadTopicById', async (global, actions, payload) => { + const { chatId, topicId } = payload; + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('fetchTopicById', { chat, topicId }); + + if (!result) { + return; + } + + global = getGlobal(); + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + global = addChats(global, buildCollectionByKey(result.chats, 'id')); + global = addMessages(global, result.messages); + global = updateTopic(global, chatId, topicId, result.topic); + + setGlobal(global); +}); + +addActionHandler('toggleForum', async (global, actions, payload) => { + const { chatId, isEnabled } = payload; + const chat = selectChat(global, chatId); + if (!chat) { + return; + } + + const prevIsForum = chat.isForum; + global = updateChat(global, chatId, { isForum: isEnabled }); + setGlobal(global); + + const result = await callApi('toggleForum', { chat, isEnabled }); + + if (!result) { + global = getGlobal(); + global = updateChat(global, chatId, { isForum: prevIsForum }); + setGlobal(global); + } +}); + +addActionHandler('deleteTopic', async (global, actions, payload) => { + const { chatId, topicId } = payload; + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('deleteTopic', { chat, topicId }); + + if (!result) return; + + global = getGlobal(); + global = deleteTopic(global, chatId, topicId); + setGlobal(global); +}); + +addActionHandler('editTopic', async (global, actions, payload) => { + const { chatId, topicId, ...rest } = payload; + const chat = selectChat(global, chatId); + const topic = chat?.topics?.[topicId]; + if (!chat || !topic) return; + + const result = await callApi('editTopic', { chat, topicId, ...rest }); + if (!result) return; + + global = getGlobal(); + global = updateTopic(global, chatId, topicId, rest); + setGlobal(global); +}); + +addActionHandler('toggleTopicPinned', (global, actions, payload) => { + const { chatId, topicId, isPinned } = payload; + + const { topicsPinnedLimit } = global.appConfig || {}; + const chat = selectChat(global, chatId); + if (!chat || !chat.topics || !topicsPinnedLimit) return; + + if (isPinned && Object.values(chat.topics).filter((topic) => topic.isPinned).length >= topicsPinnedLimit) { + actions.showNotification({ + message: langProvider.getTranslation('LimitReachedPinnedTopics', topicsPinnedLimit, 'i'), + }); + return; + } + + void callApi('togglePinnedTopic', { chat, topicId, isPinned }); +}); + async function loadChats( listType: 'active' | 'archived', offsetId?: string, @@ -1674,6 +1875,7 @@ async function getAttachBotOrNotify(global: GlobalState, username: string) { async function openChatByUsername( actions: GlobalActions, username: string, + threadId?: number, channelPostId?: number, startParam?: string, startAttach?: string | boolean, @@ -1715,9 +1917,9 @@ async function openChatByUsername( } if (channelPostId) { - actions.focusMessage({ chatId: chat.id, messageId: channelPostId }); + actions.focusMessage({ chatId: chat.id, threadId, messageId: channelPostId }); } else if (!isCurrentChat) { - actions.openChat({ id: chat.id }); + actions.openChat({ id: chat.id, threadId }); } if (startParam) { diff --git a/src/global/actions/api/globalSearch.ts b/src/global/actions/api/globalSearch.ts index 0702dbed9..d89f15e62 100644 --- a/src/global/actions/api/globalSearch.ts +++ b/src/global/actions/api/globalSearch.ts @@ -1,12 +1,15 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { callApi } from '../../../api/gramjs'; -import type { ApiChat, ApiGlobalMessageSearchType } from '../../../api/types'; +import type { + ApiChat, ApiTopic, ApiGlobalMessageSearchType, ApiMessage, ApiUser, +} from '../../../api/types'; import { addChats, addMessages, addUsers, + updateTopics, updateGlobalSearch, updateGlobalSearchFetchingStatus, updateGlobalSearchResults, @@ -14,7 +17,7 @@ import { import { throttle } from '../../../util/schedulers'; import { selectChat, selectCurrentGlobalSearchQuery } from '../../selectors'; import { buildCollectionByKey } from '../../../util/iteratees'; -import { GLOBAL_SEARCH_SLICE } from '../../../config'; +import { GLOBAL_SEARCH_SLICE, GLOBAL_TOPIC_SEARCH_SLICE } from '../../../config'; import { timestampPlusDay } from '../../../util/dateFormat'; const searchThrottled = throttle((cb) => cb(), 500, false); @@ -107,10 +110,18 @@ async function searchChats(query: string) { async function searchMessagesGlobal( query = '', type: ApiGlobalMessageSearchType, offsetRate?: number, chat?: ApiChat, maxDate?: number, minDate?: number, ) { - let result; + let result: { + messages: ApiMessage[]; + users: ApiUser[]; + chats: ApiChat[]; + topics?: ApiTopic[]; + totalTopicsCount?: number; + totalCount: number; + nextRate: number | undefined; + } | undefined; if (chat) { - const localResult = await callApi('searchMessagesLocal', { + const localResultRequest = callApi('searchMessagesLocal', { chat, query, type, @@ -119,13 +130,24 @@ async function searchMessagesGlobal( minDate, maxDate, }); + const topicsRequest = chat.isForum ? callApi('fetchTopics', { + chat, + query, + limit: GLOBAL_TOPIC_SEARCH_SLICE, + }) : undefined; + + const [localResult, topics] = await Promise.all([localResultRequest, topicsRequest]); if (localResult) { const { messages, users, totalCount, nextOffsetId, } = localResult; + const { topics: localTopics, count } = topics || {}; + result = { + topics: localTopics, + totalTopicsCount: count, messages, users, chats: [], @@ -175,5 +197,14 @@ async function searchMessagesGlobal( nextRate, ); + if (result.topics) { + global = updateTopics(global, chat!.id, result.totalTopicsCount!, result.topics); + } + + const sortedTopics = result.topics?.map(({ id }) => id).sort((a, b) => b - a); + global = updateGlobalSearch(global, { + foundTopicIds: sortedTopics, + }); + setGlobal(global); } diff --git a/src/global/actions/api/initial.ts b/src/global/actions/api/initial.ts index 1c04abc40..1c7c46ab8 100644 --- a/src/global/actions/api/initial.ts +++ b/src/global/actions/api/initial.ts @@ -50,6 +50,7 @@ addActionHandler('initApi', async (global, actions) => { maxBufferSize: MAX_BUFFER_SIZE, webAuthToken: initialLocationHash?.tgWebAuthToken, dcId: initialLocationHash?.tgWebAuthDcId ? Number(initialLocationHash?.tgWebAuthDcId) : undefined, + mockScenario: initialLocationHash?.mockScenario, }); }); diff --git a/src/global/actions/api/localSearch.ts b/src/global/actions/api/localSearch.ts index 95f801052..ad6eb2e29 100644 --- a/src/global/actions/api/localSearch.ts +++ b/src/global/actions/api/localSearch.ts @@ -19,6 +19,7 @@ import { addChatMessagesById, addChats, addUsers, + updateListedIds, updateLocalMediaSearchResults, updateLocalTextSearchResults, } from '../../reducers'; @@ -45,8 +46,8 @@ addActionHandler('searchTextMessagesLocal', (global) => { }); addActionHandler('searchMediaMessagesLocal', (global) => { - const { chatId } = selectCurrentMessageList(global) || {}; - if (!chatId) { + const { chatId, threadId } = selectCurrentMessageList(global) || {}; + if (!chatId || !threadId) { return; } @@ -65,7 +66,7 @@ addActionHandler('searchMediaMessagesLocal', (global) => { return; } - void searchSharedMedia(chat, type, offsetId); + void searchSharedMedia(chat, threadId, type, offsetId); }); addActionHandler('searchMessagesByDate', (global, actions, payload) => { @@ -131,6 +132,7 @@ async function searchTextMessages( async function searchSharedMedia( chat: ApiChat, + threadId: number, type: SharedMediaType, offsetId?: number, isBudgetPreload = false, @@ -139,6 +141,7 @@ async function searchSharedMedia( chat, type, limit: SHARED_MEDIA_SLICE * 2, + topMessageId: threadId === MAIN_THREAD_ID ? undefined : threadId, offsetId, }); @@ -163,11 +166,12 @@ async function searchSharedMedia( global = addChats(global, buildCollectionByKey(chats, 'id')); global = addUsers(global, buildCollectionByKey(users, 'id')); global = addChatMessagesById(global, chat.id, byId); - global = updateLocalMediaSearchResults(global, chat.id, type, newFoundIds, totalCount, nextOffsetId); + global = updateLocalMediaSearchResults(global, chat.id, threadId, type, newFoundIds, totalCount, nextOffsetId); + global = updateListedIds(global, chat.id, threadId, newFoundIds); setGlobal(global); if (!isBudgetPreload) { - searchSharedMedia(chat, type, nextOffsetId, true); + void searchSharedMedia(chat, threadId, type, nextOffsetId, true); } } diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 81e8367cd..dc5e21048 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -45,6 +45,8 @@ import { updateChat, updateThreadUnreadFromForwardedMessage, updateSponsoredMessage, + updateTopic, + updateThreadInfo, } from '../../reducers'; import { selectChat, @@ -71,6 +73,7 @@ import { selectSponsoredMessage, selectIsCurrentUserPremium, selectForwardsContainVoiceMessages, + selectThreadIdFromMessage, } from '../../selectors'; import { debounce, onTickEnd, rafPromise, @@ -217,11 +220,13 @@ addActionHandler('sendMessage', (global, actions, payload) => { } const chat = selectChat(global, chatId)!; + const replyingToTopId = chat.isForum ? selectThreadTopMessageId(global, chatId, threadId) : undefined; const params = { ...payload, chat, replyingTo: selectReplyingToId(global, chatId, threadId), + replyingToTopId, noWebPage: selectNoWebPage(global, chatId, threadId), sendAs: selectSendAs(global, chatId), }; @@ -337,20 +342,19 @@ addActionHandler('saveDraft', async (global, actions, payload) => { const user = selectUser(global, chatId)!; if (user && isDeletedUser(user)) return; - if (threadId === MAIN_THREAD_ID) { - const result = await callApi('saveDraft', { - chat, - text, - entities, - replyToMsgId: selectReplyingToId(global, chatId, threadId), - }); + const result = await callApi('saveDraft', { + chat, + text, + entities, + replyToMsgId: selectReplyingToId(global, chatId, threadId), + threadId: selectThreadTopMessageId(global, chatId, threadId), + }); - if (!result) { - draft.isLocal = true; - } + if (!result) { + draft.isLocal = true; } - global = getGlobal(); + global = getGlobal(); global = replaceThreadParam(global, chatId, threadId, 'draft', draft); global = updateChat(global, chatId, { draftDate: Math.round(Date.now() / 1000) }); @@ -365,8 +369,8 @@ addActionHandler('clearDraft', (global, actions, payload) => { const chat = selectChat(global, chatId)!; - if (!localOnly && threadId === MAIN_THREAD_ID) { - void callApi('clearDraft', chat); + if (!localOnly) { + void callApi('clearDraft', chat, selectThreadTopMessageId(global, chatId, threadId)); } global = replaceThreadParam(global, chatId, threadId, 'draft', undefined); @@ -397,18 +401,20 @@ addActionHandler('pinMessage', (global, actions, payload) => { }); addActionHandler('unpinAllMessages', (global, actions, payload) => { - const chat = selectChat(global, payload.chatId); + const { chatId, threadId } = payload; + const chat = selectChat(global, chatId); if (!chat) { return; } - void unpinAllMessages(chat); + void unpinAllMessages(chat, selectThreadTopMessageId(global, chatId, threadId)); }); -async function unpinAllMessages(chat: ApiChat) { - await callApi('unpinAllMessages', { chat }); +async function unpinAllMessages(chat: ApiChat, threadId?: number) { + await callApi('unpinAllMessages', { chat, threadId }); + let global = getGlobal(); - global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'pinnedIds', []); + global = replaceThreadParam(global, chat.id, threadId || MAIN_THREAD_ID, 'pinnedIds', []); setGlobal(global); } @@ -520,11 +526,6 @@ addActionHandler('markMessageListRead', (global, actions, payload) => { }); }); - // TODO Support local marking read for threads - if (threadId !== MAIN_THREAD_ID) { - return undefined; - } - if (chatId === SERVICE_NOTIFICATIONS_USER_ID) { global = { ...global, @@ -545,6 +546,27 @@ addActionHandler('markMessageListRead', (global, actions, payload) => { return global; } + if (chat.isForum && chat.topics?.[threadId]) { + const topic = chat.topics[threadId]; + global = updateThreadInfo(global, chatId, threadId, { + lastReadInboxMessageId: maxId, + }); + const newTopicUnreadCount = Math.max(0, topic.unreadCount - readCount); + if (newTopicUnreadCount === 0) { + global = updateChat(global, chatId, { + unreadCount: Math.max(0, chat.unreadCount - 1), + }); + } + return updateTopic(global, chatId, threadId, { + unreadCount: newTopicUnreadCount, + }); + } + + // TODO Support local marking read for comments + if (threadId !== MAIN_THREAD_ID) { + return undefined; + } + return updateChat(global, chatId, { lastReadInboxMessageId: maxId, unreadCount: Math.max(0, chat.unreadCount - readCount), @@ -623,7 +645,7 @@ addActionHandler('loadExtendedMedia', (global, actions, payload) => { addActionHandler('forwardMessages', (global, action, payload) => { const { - fromChatId, messageIds, toChatId, withMyScore, noAuthors, noCaptions, + fromChatId, messageIds, toChatId, withMyScore, noAuthors, noCaptions, toThreadId, } = global.forwardMessages; const isCurrentUserPremium = selectIsCurrentUserPremium(global); const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined; @@ -634,7 +656,7 @@ addActionHandler('forwardMessages', (global, action, payload) => { .map((id) => selectChatMessage(global, fromChatId, id)).filter(Boolean) : undefined; - if (!fromChat || !toChat || !messages) { + if (!fromChat || !toChat || !messages || (toThreadId && !toChat.isForum)) { return; } @@ -646,6 +668,7 @@ addActionHandler('forwardMessages', (global, action, payload) => { void callApi('forwardMessages', { fromChat, toChat, + toThreadId, messages: realMessages, serverTimeOffset: getGlobal().serverTimeOffset, isSilent, @@ -666,6 +689,7 @@ addActionHandler('forwardMessages', (global, action, payload) => { void sendMessage({ chat: toChat, + replyingToTopId: toThreadId, text, entities, sticker, @@ -1050,13 +1074,13 @@ async function loadPollOptionResults( } addActionHandler('loadPinnedMessages', (global, actions, payload) => { - const { chatId } = payload; + const { chatId, threadId } = payload; const chat = selectChat(global, chatId); if (!chat) { return; } - void loadPinnedMessages(chat); + void loadPinnedMessages(chat, threadId); }); addActionHandler('loadSeenBy', async (global, actions, payload) => { @@ -1117,8 +1141,8 @@ addActionHandler('loadSendAs', async (global, actions, payload) => { setGlobal(global); }); -async function loadPinnedMessages(chat: ApiChat) { - const result = await callApi('fetchPinnedMessages', { chat }); +async function loadPinnedMessages(chat: ApiChat, threadId = MAIN_THREAD_ID) { + const result = await callApi('fetchPinnedMessages', { chat, threadId }); if (!result) { return; } @@ -1130,7 +1154,7 @@ async function loadPinnedMessages(chat: ApiChat) { let global = getGlobal(); global = addChatMessagesById(global, chat.id, byId); - global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'pinnedIds', ids); + global = replaceThreadParam(global, chat.id, threadId, 'pinnedIds', ids); global = addUsers(global, buildCollectionByKey(users, 'id')); global = addChats(global, buildCollectionByKey(chats, 'id')); setGlobal(global); @@ -1150,6 +1174,19 @@ async function loadScheduledHistory(chat: ApiChat) { let global = getGlobal(); global = replaceScheduledMessages(global, chat.id, byId); global = replaceThreadParam(global, chat.id, MAIN_THREAD_ID, 'scheduledIds', ids); + if (chat?.isForum) { + const scheduledPerThread: Record = {}; + messages.forEach((message) => { + const threadId = selectThreadIdFromMessage(global, message); + const scheduledInThread = scheduledPerThread[threadId] || []; + scheduledInThread.push(message.id); + scheduledPerThread[threadId] = scheduledInThread; + }); + + Object.entries(scheduledPerThread).forEach(([threadId, scheduledIds]) => { + global = replaceThreadParam(global, chat.id, Number(threadId), 'scheduledIds', scheduledIds); + }); + } setGlobal(global); } @@ -1278,13 +1315,13 @@ addActionHandler('openUrl', (global, actions, payload) => { } }); -addActionHandler('setForwardChatId', async (global, actions, payload) => { - const { id } = payload; - let user = selectUser(global, id); +addActionHandler('setForwardChatOrTopic', async (global, actions, payload) => { + const { chatId, topicId } = payload; + let user = selectUser(global, chatId); if (user && selectForwardsContainVoiceMessages(global)) { if (!user.fullInfo) { const { accessHash } = user; - user = await callApi('fetchFullUser', { id, accessHash }); + user = await callApi('fetchFullUser', { id: chatId, accessHash }); global = getGlobal(); } @@ -1302,12 +1339,13 @@ addActionHandler('setForwardChatId', async (global, actions, payload) => { ...global, forwardMessages: { ...global.forwardMessages, - toChatId: id, + toChatId: chatId, + toThreadId: topicId, isModalShown: false, }, }); - actions.openChat({ id }); + actions.openChat({ id: chatId, threadId: topicId }); actions.closeMediaViewer(); actions.exitMessageSelectMode(); }); diff --git a/src/global/actions/api/sync.ts b/src/global/actions/api/sync.ts index d86fa97d9..ce6c17812 100644 --- a/src/global/actions/api/sync.ts +++ b/src/global/actions/api/sync.ts @@ -28,6 +28,7 @@ import { selectThreadInfo, selectEditingId, selectEditingDraft, + selectChatMessages, } from '../../selectors'; import { init as initFolderManager } from '../../../util/folderManager'; @@ -84,32 +85,44 @@ async function loadAndReplaceMessages() { let areMessagesLoaded = false; let global = getGlobal(); - + const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; + 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); const draftsByChatId = draftChatIds.reduce>>((acc, chatId) => { acc[chatId] = {}; - acc[chatId].draft = selectDraft(global, chatId, MAIN_THREAD_ID); - acc[chatId].editingId = selectEditingId(global, chatId, MAIN_THREAD_ID); - acc[chatId].editingDraft = selectEditingDraft(global, chatId, MAIN_THREAD_ID); + acc[chatId].draft = selectDraft(global, chatId, activeThreadId); + acc[chatId].editingId = selectEditingId(global, chatId, activeThreadId); + acc[chatId].editingDraft = selectEditingDraft(global, chatId, activeThreadId); return acc; }, {}); - const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; - const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined; - if (currentChatId && currentChat) { - const result = await loadTopMessages(currentChat); + const currentChat = activeCurrentChatId ? global.chats.byId[activeCurrentChatId] : undefined; + if (activeCurrentChatId && currentChat) { + if (currentChat.isForum) { + getActions().loadTopics({ chatId: activeCurrentChatId, force: true }); + } + const result = await loadTopMessages(currentChat, activeThreadId, threadInfo?.lastReadInboxMessageId); global = getGlobal(); const { chatId: newCurrentChatId } = selectCurrentMessageList(global) || {}; - const threadInfo = currentThreadId && selectThreadInfo(global, currentChatId, currentThreadId); if (result && newCurrentChatId === currentChatId) { - const currentMessageListInfo = global.messages.byChatId[currentChatId]; + const currentChatMessages = selectChatMessages(global, activeCurrentChatId); const localMessages = currentChatId === SERVICE_NOTIFICATIONS_USER_ID ? global.serviceNotifications.filter(({ isDeleted }) => !isDeleted).map(({ message }) => message) : []; - const allMessages = ([] as ApiMessage[]).concat(result.messages, localMessages); + const topicLastMessages = currentChat.isForum && currentChat.topics + ? Object.values(currentChat.topics) + .map(({ lastMessageId }) => currentChatMessages[lastMessageId]) + .filter(Boolean) + : []; + + const allMessages = ([] as ApiMessage[]).concat(result.messages, localMessages, topicLastMessages); const byId = buildCollectionByKey(allMessages, 'id'); const listedIds = Object.keys(byId).map(Number); @@ -121,55 +134,12 @@ async function loadAndReplaceMessages() { }, }; - global = addChatMessagesById(global, currentChatId, byId); - global = updateListedIds(global, currentChatId, MAIN_THREAD_ID, listedIds); - global = safeReplaceViewportIds(global, currentChatId, MAIN_THREAD_ID, listedIds); - - if (currentThreadId && threadInfo && threadInfo.originChannelId) { - const { originChannelId } = threadInfo; - const currentMessageListInfoOrigin = global.messages.byChatId[originChannelId]; - const resultOrigin = await loadTopMessages(global.chats.byId[originChannelId]); - global = getGlobal(); - if (resultOrigin) { - const byIdOrigin = buildCollectionByKey(resultOrigin.messages, 'id'); - const listedIdsOrigin = Object.keys(byIdOrigin).map(Number); - - global = { - ...global, - messages: { - ...global.messages, - byChatId: { - ...global.messages.byChatId, - [threadInfo.originChannelId]: { - byId: byIdOrigin, - threadsById: { - [MAIN_THREAD_ID]: { - ...(currentMessageListInfoOrigin?.threadsById[MAIN_THREAD_ID]), - listedIds: listedIdsOrigin, - viewportIds: listedIdsOrigin, - outlyingIds: undefined, - }, - }, - }, - [currentChatId]: { - ...global.messages.byChatId[currentChatId], - threadsById: { - ...global.messages.byChatId[currentChatId].threadsById, - [currentThreadId]: { - ...(currentMessageListInfo?.threadsById[currentThreadId]), - outlyingIds: undefined, - }, - }, - }, - }, - }, - }; - } - } - + global = addChatMessagesById(global, activeCurrentChatId, byId); + global = updateListedIds(global, activeCurrentChatId, activeThreadId, listedIds); + global = safeReplaceViewportIds(global, activeCurrentChatId, activeThreadId, listedIds); global = updateChats(global, buildCollectionByKey(result.chats, 'id')); global = updateUsers(global, buildCollectionByKey(result.users, 'id')); - global = updateThreadInfos(global, currentChatId, result.threadInfos); + global = updateThreadInfos(global, activeCurrentChatId, result.threadInfos); areMessagesLoaded = true; } @@ -187,7 +157,7 @@ async function loadAndReplaceMessages() { // Restore drafts Object.keys(draftsByChatId).forEach((chatId) => { - global = updateThread(global, chatId, MAIN_THREAD_ID, draftsByChatId[chatId]); + global = updateThread(global, chatId, activeThreadId, draftsByChatId[chatId]); }); setGlobal(global); @@ -198,11 +168,11 @@ async function loadAndReplaceMessages() { } } -function loadTopMessages(chat: ApiChat) { +function loadTopMessages(chat: ApiChat, threadId: number, lastReadInboxId?: number) { return callApi('fetchMessages', { chat, - threadId: MAIN_THREAD_ID, - offsetId: chat.lastReadInboxMessageId, + threadId, + offsetId: lastReadInboxId || chat.lastReadInboxMessageId, addOffset: -(Math.round(MESSAGE_LIST_SLICE / 2) + 1), limit: MESSAGE_LIST_SLICE, }); diff --git a/src/global/actions/apiUpdaters/chats.ts b/src/global/actions/apiUpdaters/chats.ts index 74a2584e9..c674118ef 100644 --- a/src/global/actions/apiUpdaters/chats.ts +++ b/src/global/actions/apiUpdaters/chats.ts @@ -10,7 +10,7 @@ import { updateChatListIds, updateChatListType, replaceThreadParam, - leaveChat, + leaveChat, updateTopic, } from '../../reducers'; import { selectChat, @@ -18,6 +18,7 @@ import { selectIsChatListed, selectChatListType, selectCurrentMessageList, + selectThreadParam, } from '../../selectors'; import { updateUnreadReactions } from '../../reducers/reactions'; @@ -28,6 +29,9 @@ const CURRENT_CHAT_UNREAD_DELAY = 1500; addActionHandler('apiUpdate', (global, actions, update) => { switch (update['@type']) { case 'updateChat': { + const { isForum: prevIsForum } = selectChat(global, update.id) || {}; + const { chatId: currentChatId } = selectCurrentMessageList(global) || {}; + setGlobal(updateChat(global, update.id, update.chat, update.newProfilePhoto)); if (!update.noTopChatsRequest && !selectIsChatListed(global, update.id)) { @@ -42,6 +46,14 @@ addActionHandler('apiUpdate', (global, actions, update) => { }); } + // The property `isForum` was changed in another client + if (currentChatId === update.id && 'isForum' in update.chat && prevIsForum !== update.chat.isForum) { + if (prevIsForum) { + actions.closeForumPanel(); + } + actions.openChat({ id: currentChatId }); + } + return undefined; } @@ -72,14 +84,14 @@ addActionHandler('apiUpdate', (global, actions, update) => { } case 'updateChatTypingStatus': { - const { id, typingStatus } = update; - setGlobal(updateChat(global, id, { typingStatus })); + const { id, threadId = MAIN_THREAD_ID, typingStatus } = update; + setGlobal(replaceThreadParam(global, id, threadId, 'typingStatus', typingStatus)); setTimeout(() => { global = getGlobal(); - const chat = selectChat(global, id); - if (chat && typingStatus && chat.typingStatus && chat.typingStatus.timestamp === typingStatus.timestamp) { - setGlobal(updateChat(global, id, { typingStatus: undefined })); + const currentTypingStatus = selectThreadParam(global, id, threadId, 'typingStatus'); + if (typingStatus && currentTypingStatus && typingStatus.timestamp === currentTypingStatus.timestamp) { + setGlobal(replaceThreadParam(global, id, threadId, 'typingStatus', undefined)); } }, TYPING_STATUS_CLEAR_DELAY); @@ -349,15 +361,15 @@ addActionHandler('apiUpdate', (global, actions, update) => { case 'draftMessage': { const { - chatId, formattedText, date, replyingToId, + chatId, formattedText, date, replyingToId, threadId, } = update; const chat = global.chats.byId[chatId]; if (!chat) { return undefined; } - global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'draft', formattedText); - global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'replyingToId', replyingToId); + global = replaceThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'draft', formattedText); + global = replaceThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'replyingToId', replyingToId); global = updateChat(global, chatId, { draftDate: date }); return global; } @@ -387,6 +399,59 @@ addActionHandler('apiUpdate', (global, actions, update) => { setGlobal(global); actions.loadChatJoinRequests({ chatId }); + return undefined; + } + + case 'updatePinnedTopic': { + const { chatId, topicId, isPinned } = update; + + const chat = global.chats.byId[chatId]; + if (!chat) { + return undefined; + } + + global = updateTopic(global, chatId, topicId, { + isPinned, + }); + setGlobal(global); + + return undefined; + } + + case 'updatePinnedTopicsOrder': { + const { chatId, order } = update; + + const chat = global.chats.byId[chatId]; + if (!chat) return undefined; + + global = updateChat(global, chatId, { + orderedPinnedTopicIds: order, + }); + setGlobal(global); + + return undefined; + } + + case 'updateTopic': { + const { chatId, topicId } = update; + + const chat = selectChat(global, chatId); + if (!chat?.isForum) return undefined; + + actions.loadTopicById({ chatId, topicId }); + + return undefined; + } + + case 'updateTopics': { + const { chatId } = update; + + const chat = selectChat(global, chatId); + if (!chat?.isForum) return undefined; + + actions.loadTopics({ chatId, force: true }); + + return undefined; } } diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 56b1d073e..27d3d5ba8 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -8,7 +8,7 @@ import type { ActiveEmojiInteraction, GlobalActions, GlobalState } from '../../t import { MAIN_THREAD_ID } from '../../../api/types'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; -import { unique } from '../../../util/iteratees'; +import { pickTruthy, unique } from '../../../util/iteratees'; import { areDeepEqual } from '../../../util/areDeepEqual'; import { notifyAboutMessage } from '../../../util/notifications'; import { @@ -22,6 +22,7 @@ import { updateScheduledMessage, deleteChatScheduledMessages, updateThreadUnreadFromForwardedMessage, + updateTopic, } from '../../reducers'; import { selectChatMessage, @@ -35,7 +36,7 @@ import { selectThreadByMessage, selectPinnedIds, selectScheduledMessage, - selectScheduledMessages, + selectChatScheduledMessages, selectIsMessageInCurrentMessageList, selectScheduledIds, selectCurrentMessageList, @@ -46,6 +47,8 @@ import { selectIsServiceChatReady, selectLocalAnimatedEmojiEffect, selectLocalAnimatedEmoji, + selectThreadIdFromMessage, + selectTopicFromMessage, } from '../../selectors'; import { getMessageContent, isUserId, isMessageLocal, getMessageText, checkIfHasUnreadReactions, @@ -74,6 +77,13 @@ addActionHandler('apiUpdate', (global, actions, update) => { } const newMessage = selectChatMessage(global, chatId, id)!; + const chat = selectChat(global, chatId); + if (chat?.isForum + && newMessage.isTopicReply + && !selectTopicFromMessage(global, newMessage) + && newMessage.replyToMessageId) { + actions.loadTopicById({ chatId, topicId: newMessage.replyToMessageId }); + } const isLocal = isMessageLocal(message as ApiMessage); if (selectIsMessageInCurrentMessageList(global, chatId, message as ApiMessage)) { @@ -91,7 +101,7 @@ addActionHandler('apiUpdate', (global, actions, update) => { } } - const { threadInfo } = selectThreadByMessage(global, chatId, message as ApiMessage) || {}; + const { threadInfo } = selectThreadByMessage(global, message as ApiMessage) || {}; if (threadInfo) { actions.requestThreadInfoUpdate({ chatId, threadId: threadInfo.threadId }); } @@ -152,9 +162,15 @@ addActionHandler('apiUpdate', (global, actions, update) => { global = updateWithLocalMedia(global, chatId, id, message, true); - const scheduledIds = selectScheduledIds(global, chatId) || []; + const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || []; global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', unique([...scheduledIds, id])); + const threadId = selectThreadIdFromMessage(global, message); + if (threadId !== MAIN_THREAD_ID) { + const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || []; + global = replaceThreadParam(global, chatId, threadId, 'scheduledIds', unique([...threadScheduledIds, id])); + } + setGlobal(global); break; @@ -214,8 +230,14 @@ addActionHandler('apiUpdate', (global, actions, update) => { } global = updateWithLocalMedia(global, chatId, id, message, true); - const ids = Object.keys(selectScheduledMessages(global, chatId) || {}).map(Number).sort((a, b) => b - a); + const ids = Object.keys(selectChatScheduledMessages(global, chatId) || {}).map(Number).sort((a, b) => b - a); global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', ids); + + const threadId = selectThreadIdFromMessage(global, currentMessage); + if (threadId !== MAIN_THREAD_ID) { + const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || []; + global = replaceThreadParam(global, chatId, threadId, 'scheduledIds', threadScheduledIds.sort((a, b) => b - a)); + } setGlobal(global); break; @@ -244,7 +266,7 @@ addActionHandler('apiUpdate', (global, actions, update) => { const newMessage = selectChatMessage(global, chatId, message.id)!; global = updateChatLastMessage(global, chatId, newMessage); - const thread = selectThreadByMessage(global, chatId, message); + const thread = selectThreadByMessage(global, message); // For some reason Telegram requires to manually mark outgoing thread messages read if (thread?.threadInfo) { actions.markMessageListRead({ maxId: message.id }); @@ -263,9 +285,15 @@ addActionHandler('apiUpdate', (global, actions, update) => { case 'updateScheduledMessageSendSucceeded': { const { chatId, localId, message } = update; - const scheduledIds = selectScheduledIds(global, chatId) || []; + const scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID) || []; global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', [...scheduledIds, message.id]); + const threadId = selectThreadIdFromMessage(global, message); + if (threadId !== MAIN_THREAD_ID) { + const threadScheduledIds = selectScheduledIds(global, chatId, threadId) || []; + global = replaceThreadParam(global, chatId, threadId, 'scheduledIds', [...threadScheduledIds, message.id]); + } + const currentMessage = selectScheduledMessage(global, chatId, localId); global = deleteChatScheduledMessages(global, chatId, [localId]); @@ -282,12 +310,26 @@ addActionHandler('apiUpdate', (global, actions, update) => { case 'updatePinnedIds': { const { chatId, isPinned, messageIds } = update; - const currentPinnedIds = selectPinnedIds(global, chatId) || []; - const newPinnedIds = isPinned - ? [...currentPinnedIds, ...messageIds].sort((a, b) => b - a) - : currentPinnedIds.filter((id) => !messageIds.includes(id)); + const messages = pickTruthy(selectChatMessages(global, chatId), messageIds); + const updatePerThread: Record = { + [MAIN_THREAD_ID]: messageIds, + }; + Object.values(messages).forEach((message) => { + const threadId = selectThreadIdFromMessage(global, message); + if (threadId === MAIN_THREAD_ID) return; + const currentUpdatedInThread = updatePerThread[threadId] || []; + currentUpdatedInThread.push(message.id); + updatePerThread[threadId] = currentUpdatedInThread; + }); - setGlobal(replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds', newPinnedIds)); + Object.entries(updatePerThread).forEach(([threadId, ids]) => { + const pinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID) || []; + const newPinnedIds = isPinned + ? unique(pinnedIds.concat(ids)).sort((a, b) => b - a) + : pinnedIds.filter((id) => !ids.includes(id)); + global = replaceThreadParam(global, chatId, Number(threadId), 'pinnedIds', newPinnedIds); + }); + setGlobal(global); break; } @@ -298,16 +340,16 @@ addActionHandler('apiUpdate', (global, actions, update) => { } = update; const currentThreadInfo = selectThreadInfo(global, chatId, threadId); - const newTheadInfo = { + const newThreadInfo = { ...currentThreadInfo, ...threadInfo, }; - if (!newTheadInfo.threadId) { + if (!newThreadInfo.threadId) { return; } - global = updateThreadInfo(global, chatId, threadId, newTheadInfo as ApiThreadInfo); + global = updateThreadInfo(global, chatId, threadId, newThreadInfo as ApiThreadInfo); if (firstMessageId) { global = replaceThreadParam(global, chatId, threadId, 'firstMessageId', firstMessageId); @@ -645,7 +687,7 @@ function updateWithLocalMedia( function updateThreadUnread(global: GlobalState, actions: GlobalActions, message: ApiMessage, isDeleting?: boolean) { const { chatId } = message; - const { threadInfo } = selectThreadByMessage(global, chatId, message) || {}; + const { threadInfo } = selectThreadByMessage(global, message) || {}; if (!threadInfo && message.replyToMessageId) { const originMessage = selectChatMessage(global, chatId, message.replyToMessageId); @@ -669,7 +711,7 @@ function updateThreadUnread(global: GlobalState, actions: GlobalActions, message function updateListedAndViewportIds(global: GlobalState, actions: GlobalActions, message: ApiMessage) { const { id, chatId } = message; - const { threadInfo, firstMessageId } = selectThreadByMessage(global, chatId, message) || {}; + const { threadInfo, firstMessageId } = selectThreadByMessage(global, message) || {}; const chat = selectChat(global, chatId); const isUnreadChatNotLoaded = chat?.unreadCount && !selectListedIds(global, chatId, MAIN_THREAD_ID); @@ -723,7 +765,8 @@ function updateChatLastMessage( force = false, ) { const { chats } = global; - const currentLastMessage = chats.byId[chatId]?.lastMessage; + const chat = chats.byId[chatId]; + const currentLastMessage = chat?.lastMessage; if (currentLastMessage && !force) { const isSameOrNewer = ( @@ -735,7 +778,15 @@ function updateChatLastMessage( } } - return updateChat(global, chatId, { lastMessage: message }); + global = updateChat(global, chatId, { lastMessage: message }); + const topic = chat.isForum ? selectTopicFromMessage(global, message) : undefined; + if (topic) { + global = updateTopic(global, chatId, topic.id, { + lastMessageId: message.id, + }); + } + + return global; } function findLastMessage(global: GlobalState, chatId: string) { @@ -784,9 +835,9 @@ function deleteMessages(chatId: string | undefined, ids: number[], actions: Glob global = updateThreadUnread(global, actions, message, true); - const { threadInfo } = selectThreadByMessage(global, chatId, message) || {}; - if (threadInfo) { - threadIdsToUpdate.push(threadInfo.threadId); + const threadId = selectThreadIdFromMessage(global, message); + if (threadId) { + threadIdsToUpdate.push(threadId); } }); @@ -851,7 +902,7 @@ function deleteScheduledMessages( setTimeout(() => { global = deleteChatScheduledMessages(getGlobal(), chatId, ids); - const scheduledMessages = selectScheduledMessages(global, chatId); + const scheduledMessages = selectChatScheduledMessages(global, chatId); global = replaceThreadParam( global, chatId, MAIN_THREAD_ID, 'scheduledIds', Object.keys(scheduledMessages || {}).map(Number), ); diff --git a/src/global/actions/apiUpdaters/settings.ts b/src/global/actions/apiUpdaters/settings.ts index 6e5dc07b0..97bfcf612 100644 --- a/src/global/actions/apiUpdaters/settings.ts +++ b/src/global/actions/apiUpdaters/settings.ts @@ -1,6 +1,8 @@ import { addActionHandler, setGlobal } from '../../index'; -import { addNotifyException, updateChat, updateNotifySettings } from '../../reducers'; +import { + addNotifyException, updateChat, updateTopic, updateNotifySettings, +} from '../../reducers'; addActionHandler('apiUpdate', (global, actions, update) => { switch (update['@type']) { @@ -21,6 +23,17 @@ addActionHandler('apiUpdate', (global, actions, update) => { setGlobal(addNotifyException(global, chatId, { isMuted, isSilent, shouldShowPreviews })); break; } + + case 'updateTopicNotifyExceptions': { + const { + chatId, topicId, isMuted, + } = update; + + global = updateTopic(global, chatId, topicId, { isMuted }); + + setGlobal(global); + break; + } } return undefined; diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index 4fbe1548f..bb190ea83 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -71,10 +71,12 @@ addActionHandler('openChatWithInfo', (global, actions, payload) => { }); addActionHandler('openChatWithDraft', (global, actions, payload) => { - const { chatId, text, files } = payload; + const { + chatId, threadId, text, files, + } = payload; if (chatId) { - actions.openChat({ id: chatId }); + actions.openChat({ id: chatId, threadId }); } return { diff --git a/src/global/actions/ui/globalSearch.ts b/src/global/actions/ui/globalSearch.ts index f1e180725..c072b2e82 100644 --- a/src/global/actions/ui/globalSearch.ts +++ b/src/global/actions/ui/globalSearch.ts @@ -17,6 +17,12 @@ addActionHandler('setGlobalSearchQuery', (global, actions, payload) => { }); }); +addActionHandler('setGlobalSearchClosing', (global, actions, payload) => { + return updateGlobalSearch(global, { + isClosing: payload, + }); +}); + addActionHandler('addRecentlyFoundChatId', (global, actions, payload) => { const { id } = payload!; const { recentlyFoundChatIds } = global.globalSearch; diff --git a/src/global/actions/ui/localSearch.ts b/src/global/actions/ui/localSearch.ts index 184128e37..4d27a527d 100644 --- a/src/global/actions/ui/localSearch.ts +++ b/src/global/actions/ui/localSearch.ts @@ -41,13 +41,13 @@ addActionHandler('setLocalTextSearchQuery', (global, actions, payload) => { }); addActionHandler('setLocalMediaSearchType', (global, actions, payload) => { - const { chatId } = selectCurrentMessageList(global) || {}; - if (!chatId) { + const { chatId, threadId } = selectCurrentMessageList(global) || {}; + if (!chatId || !threadId) { return undefined; } const { mediaType } = payload!; - return updateLocalMediaSearchType(global, chatId, mediaType); + return updateLocalMediaSearchType(global, chatId, threadId, mediaType); }); export function closeLocalTextSearch(global: GlobalState): GlobalState { diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index acddbd274..36833f8d3 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -35,7 +35,7 @@ import { selectReplyingToId, selectReplyStack, selectSender, - selectScheduledMessages, + selectChatScheduledMessages, } from '../../selectors'; import { findLast } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; @@ -287,7 +287,8 @@ addActionHandler('closePollResults', (global) => { }; }); -addActionHandler('focusLastMessage', (global, actions) => { +addActionHandler('focusLastMessage', (global, actions, payload) => { + const { noForumTopicPanel } = payload || {}; const currentMessageList = selectCurrentMessageList(global); if (!currentMessageList) { return; @@ -311,7 +312,7 @@ addActionHandler('focusLastMessage', (global, actions) => { } actions.focusMessage({ - chatId, threadId, messageId: lastMessageId, noHighlight: true, + chatId, threadId, messageId: lastMessageId, noHighlight: true, noForumTopicPanel, }); }); @@ -326,7 +327,7 @@ addActionHandler('focusNextReply', (global, actions) => { const replyStack = selectReplyStack(global, chatId, threadId); if (!replyStack || replyStack.length === 0) { - actions.focusLastMessage(); + actions.focusLastMessage({ noForumTopicPanel: true }); } else { const messageId = replyStack.pop(); @@ -338,6 +339,7 @@ addActionHandler('focusNextReply', (global, actions) => { chatId, threadId, messageId, + noForumTopicPanel: true, }); } @@ -347,7 +349,7 @@ addActionHandler('focusNextReply', (global, actions) => { addActionHandler('focusMessage', (global, actions, payload) => { const { chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, - replyMessageId, isResizingContainer, shouldReplaceHistory, + replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, } = payload!; let { messageId } = payload!; @@ -392,7 +394,12 @@ addActionHandler('focusMessage', (global, actions, payload) => { const viewportIds = selectViewportIds(global, chatId, threadId); if (viewportIds && viewportIds.includes(messageId)) { setGlobal(global); - actions.openChat({ id: chatId, threadId, shouldReplaceHistory }); + actions.openChat({ + id: chatId, + threadId, + shouldReplaceHistory, + noForumTopicPanel, + }); return undefined; } @@ -409,7 +416,12 @@ addActionHandler('focusMessage', (global, actions, payload) => { setGlobal(global); - actions.openChat({ id: chatId, threadId, shouldReplaceHistory }); + actions.openChat({ + id: chatId, + threadId, + shouldReplaceHistory, + noForumTopicPanel, + }); actions.loadViewportMessages(); return undefined; }); @@ -741,7 +753,7 @@ function copyTextForMessages(global: GlobalState, chatId: string, messageIds: nu const lang = langProvider.getTranslation; const chatMessages = messageListType === 'scheduled' - ? selectScheduledMessages(global, chatId) + ? selectChatScheduledMessages(global, chatId) : selectChatMessages(global, chatId); if (!chatMessages || !threadId) return; const messages = messageIds diff --git a/src/global/cache.ts b/src/global/cache.ts index 61a97f2bc..985125f38 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -25,8 +25,9 @@ import { } from '../config'; import { IS_SINGLE_COLUMN_LAYOUT } from '../util/environment'; import { isHeavyAnimating } from '../hooks/useHeavyAnimationCheck'; -import { pick, unique } from '../util/iteratees'; +import { pick, pickTruthy, unique } from '../util/iteratees'; import { + selectChat, selectCurrentChat, selectCurrentMessageList, selectVisibleUsers, @@ -298,6 +299,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { cached.recentCustomEmojis = []; } + if (!cached?.localMediaSearch?.byChatThreadKey) { + cached.localMediaSearch = initialState.localMediaSearch; + } + if (cached.settings.byKey.shouldSuggestCustomEmoji === undefined) { cached.settings.byKey.shouldSuggestCustomEmoji = true; } @@ -525,6 +530,7 @@ function reduceMessages(global: GlobalState): GlobalState['messages'] { const chatIdsToSave = [ ...currentChatId ? [currentChatId] : [], ...currentUserId ? [currentUserId] : [], + ...global.forumPanelChatId ? [global.forumPanelChatId] : [], ...getOrderedIds(ALL_FOLDER_ID)?.slice(0, GLOBAL_STATE_CACHE_CHATS_WITH_MESSAGES_LIMIT) || [], ]; @@ -534,16 +540,21 @@ function reduceMessages(global: GlobalState): GlobalState['messages'] { return; } - const mainThread = current.threadsById[MAIN_THREAD_ID]; - if (!mainThread || !mainThread.viewportIds) { + const chat = selectChat(global, chatId); + + const threadIdsToSave = currentChatId === chatId && threadId ? [MAIN_THREAD_ID, threadId] : [MAIN_THREAD_ID]; + const threadsToSave = pickTruthy(current.threadsById, threadIdsToSave); + if (!Object.keys(threadsToSave).length) { return; } + const viewportIdsToSave = unique(Object.values(threadsToSave).flatMap((thread) => thread.viewportIds || [])); + const lastMessagesToSave = chat?.topics + ? Object.values(chat.topics).map(({ lastMessageId }) => lastMessageId) : []; + byChatId[chatId] = { - byId: pick(current.byId, mainThread.viewportIds), - threadsById: { - [MAIN_THREAD_ID]: mainThread, - }, + byId: pick(current.byId, viewportIdsToSave.concat(lastMessagesToSave)), + threadsById: threadsToSave, }; }); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index 027dfeb05..a8ada3d4b 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -3,7 +3,7 @@ import type { ApiUser, ApiChatBannedRights, ApiChatAdminRights, - ApiChatFolder, + ApiChatFolder, ApiTopic, } from '../../api/types'; import { MAIN_THREAD_ID, @@ -107,6 +107,17 @@ export function getChatLink(chat: ApiChat) { return inviteLink; } +export function getChatMessageLink(chatId: string, chatUsername?: string, threadId?: number, messageId?: number) { + const chatPart = chatUsername || `c/${chatId.replace('-', '')}`; + const threadPart = threadId && threadId !== MAIN_THREAD_ID ? `/${threadId}` : ''; + const messagePart = messageId ? `/${messageId}` : ''; + return `${TME_LINK_PREFIX}${chatPart}${threadPart}${messagePart}`; +} + +export function getTopicLink(chatId: string, chatUsername?: string, topicId?: number) { + return getChatMessageLink(chatId, chatUsername, topicId); +} + export function getChatAvatarHash( owner: ApiChat | ApiUser, size: 'normal' | 'big' = 'normal', @@ -153,7 +164,17 @@ export function isUserRightBanned(chat: ApiChat, key: keyof ApiChatBannedRights) export function getCanPostInChat(chat: ApiChat, threadId: number) { if (threadId !== MAIN_THREAD_ID) { - return true; + if (chat.isForum) { + if (chat.isNotJoined) { + return false; + } + + const topic = chat.topics?.[threadId]; + if (topic?.isClosed && !topic.isOwner && !getHasAdminRight(chat, 'manageTopics')) { + return false; + } + } + return true; // TODO[forums] legacy value, check that again } if (chat.isRestricted || chat.isForbidden || chat.migratedTo || chat.isNotJoined || isChatWithRepliesBot(chat.id)) { @@ -230,6 +251,30 @@ export function getMessageSendingRestrictionReason( return undefined; } +export function getForumComposerPlaceholder( + lang: LangFn, chat?: ApiChat, threadId = MAIN_THREAD_ID, isReplying?: boolean, +) { + if (!chat?.isForum) { + return undefined; + } + + if (threadId === MAIN_THREAD_ID) { + if (isReplying) return undefined; + return lang('lng_forum_replies_only'); + } + + const topic = chat.topics?.[threadId]; + if (!topic) { + return undefined; + } + + if (topic.isClosed && !topic.isOwner && !getHasAdminRight(chat, 'manageTopics')) { + return lang('TopicClosedByAdmin'); + } + + return undefined; +} + export function getChatSlowModeOptions(chat?: ApiChat) { if (!chat || !chat.fullInfo) { return undefined; @@ -315,11 +360,19 @@ export function getFolderDescriptionText(lang: LangFn, folder: ApiChatFolder, ch } } -export function getMessageSenderName(lang: LangFn, chatId: string, sender?: ApiUser) { +export function getMessageSenderName(lang: LangFn, chatId: string, sender?: ApiUser | ApiChat) { if (!sender || isUserId(chatId)) { return undefined; } + if (!isUserId(sender.id)) { + if (chatId === sender.id) return undefined; + + return (sender as ApiChat).title; + } + + sender = sender as ApiUser; + if (sender.isSelf) { return lang('FromYou'); } @@ -383,3 +436,23 @@ export function filterChatsByName( export function isChatPublic(chat: ApiChat) { return chat.usernames?.some(({ isActive }) => isActive); } + +export function getOrderedTopics( + topics: ApiTopic[], pinnedOrder?: number[], shouldSortByLastMessage = false, +): ApiTopic[] { + if (shouldSortByLastMessage) { + return topics.sort((a, b) => b.lastMessageId - a.lastMessageId); + } else { + const pinned = topics.filter((topic) => topic.isPinned); + const ordered = topics + .filter((topic) => !topic.isPinned && !topic.isHidden) + .sort((a, b) => b.lastMessageId - a.lastMessageId); + const hidden = topics.filter((topic) => !topic.isPinned && topic.isHidden) + .sort((a, b) => b.lastMessageId - a.lastMessageId); + + const pinnedOrdered = pinnedOrder + ? pinnedOrder.map((id) => pinned.find((topic) => topic.id === id)).filter(Boolean) : pinned; + + return [...pinnedOrdered, ...ordered, ...hidden]; + } +} diff --git a/src/global/initialState.ts b/src/global/initialState.ts index a93b2175a..69be0ef06 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -120,7 +120,7 @@ export const INITIAL_STATE: GlobalState = { }, localMediaSearch: { - byChatId: {}, + byChatThreadKey: {}, }, management: { diff --git a/src/global/reducers/chats.ts b/src/global/reducers/chats.ts index 063a364fc..b7912d3f3 100644 --- a/src/global/reducers/chats.ts +++ b/src/global/reducers/chats.ts @@ -1,9 +1,14 @@ import type { GlobalState } from '../types'; -import type { ApiChat, ApiChatMember, ApiPhoto } from '../../api/types'; +import type { + ApiChat, ApiChatMember, ApiTopic, ApiPhoto, +} from '../../api/types'; import { ARCHIVED_FOLDER_ID } from '../../config'; -import { areSortedArraysEqual, buildCollectionByKey, omit } from '../../util/iteratees'; -import { selectChatListType } from '../selectors'; +import { + areSortedArraysEqual, buildCollectionByKey, omit, +} from '../../util/iteratees'; +import { selectChat, selectChatListType } from '../selectors'; +import { updateThread, updateThreadInfo } from './messages'; export function replaceChatListIds( global: GlobalState, @@ -246,3 +251,82 @@ export function addChatMembers(global: GlobalState, chat: ApiChat, membersToAdd: }, }); } + +export function updateTopics( + global: GlobalState, chatId: string, topicsCount: number, topics: ApiTopic[], +): GlobalState { + const chat = selectChat(global, chatId); + + const newTopics = buildCollectionByKey(topics, 'id'); + + global = updateChat(global, chatId, { + topics: { + ...chat?.topics, + ...newTopics, + }, + topicsCount, + }); + + topics.forEach((topic) => { + global = updateThread(global, chatId, topic.id, { + firstMessageId: topic.id, + }); + + global = updateThreadInfo(global, chatId, topic.id, { + lastMessageId: topic.lastMessageId, + threadId: topic.id, + chatId, + }); + }); + + return global; +} + +export function updateTopic( + global: GlobalState, chatId: string, topicId: number, update: Partial, +): GlobalState { + const chat = selectChat(global, chatId); + + if (!chat) return global; + + const topic = chat?.topics?.[topicId]; + + const updatedTopic = { + ...topic, + ...update, + } as ApiTopic; + + if (!updatedTopic.id) return global; + + global = updateChat(global, chatId, { + topics: { + ...(chat.topics || {}), + [topicId]: updatedTopic, + }, + }); + + global = updateThread(global, chatId, updatedTopic.id, { + firstMessageId: updatedTopic.id, + }); + + global = updateThreadInfo(global, chatId, updatedTopic.id, { + lastMessageId: updatedTopic.lastMessageId, + threadId: updatedTopic.id, + chatId, + }); + + return global; +} + +export function deleteTopic( + global: GlobalState, chatId: string, topicId: number, +) { + const chat = selectChat(global, chatId); + const topics = chat?.topics || []; + + global = updateChat(global, chatId, { + topics: omit(topics, [topicId]), + }); + + return global; +} diff --git a/src/global/reducers/localSearch.ts b/src/global/reducers/localSearch.ts index ec7a34631..58b99398e 100644 --- a/src/global/reducers/localSearch.ts +++ b/src/global/reducers/localSearch.ts @@ -96,14 +96,17 @@ export function updateLocalTextSearchResults( function replaceLocalMediaSearch( global: GlobalState, chatId: string, + threadId: number, searchParams: MediaSearchParams, ): GlobalState { + const chatThreadKey = buildChatThreadKey(chatId, threadId); + return { ...global, localMediaSearch: { - byChatId: { - ...global.localMediaSearch.byChatId, - [chatId]: searchParams, + byChatThreadKey: { + ...global.localMediaSearch.byChatThreadKey, + [chatThreadKey]: searchParams, }, }, }; @@ -112,10 +115,13 @@ function replaceLocalMediaSearch( export function updateLocalMediaSearchType( global: GlobalState, chatId: string, + threadId: number, currentType: SharedMediaType | undefined, ): GlobalState { - return replaceLocalMediaSearch(global, chatId, { - ...global.localMediaSearch.byChatId[chatId], + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return replaceLocalMediaSearch(global, chatId, threadId, { + ...global.localMediaSearch.byChatThreadKey[chatThreadKey], currentType, }); } @@ -123,15 +129,18 @@ export function updateLocalMediaSearchType( export function replaceLocalMediaSearchResults( global: GlobalState, chatId: string, + threadId: number, type: ApiMessageSearchType, foundIds?: number[], totalCount?: number, nextOffsetId?: number, ): GlobalState { - return replaceLocalMediaSearch(global, chatId, { - ...global.localMediaSearch.byChatId[chatId], + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return replaceLocalMediaSearch(global, chatId, threadId, { + ...global.localMediaSearch.byChatThreadKey[chatThreadKey], resultsByType: { - ...(global.localMediaSearch.byChatId[chatId] || {}).resultsByType, + ...(global.localMediaSearch.byChatThreadKey[chatThreadKey] || {}).resultsByType, [type]: { foundIds, totalCount, @@ -144,17 +153,28 @@ export function replaceLocalMediaSearchResults( export function updateLocalMediaSearchResults( global: GlobalState, chatId: string, + threadId: number, type: SharedMediaType, newFoundIds: number[], totalCount?: number, nextOffsetId?: number, ): GlobalState { - const { resultsByType } = global.localMediaSearch.byChatId[chatId] || {}; + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + const { resultsByType } = global.localMediaSearch.byChatThreadKey[chatThreadKey] || {}; const prevFoundIds = resultsByType?.[type] ? resultsByType[type]!.foundIds : []; const foundIds = orderFoundIds(unique(Array.prototype.concat(prevFoundIds, newFoundIds))); const foundOrPrevFoundIds = areSortedArraysEqual(prevFoundIds, foundIds) ? prevFoundIds : foundIds; - return replaceLocalMediaSearchResults(global, chatId, type, foundOrPrevFoundIds, totalCount, nextOffsetId); + return replaceLocalMediaSearchResults( + global, + chatId, + threadId, + type, + foundOrPrevFoundIds, + totalCount, + nextOffsetId, + ); } function orderFoundIds(listedIds: number[]) { diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index ca0367ec4..ea3455d97 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -17,11 +17,12 @@ import { selectPinnedIds, selectThreadInfo, selectMessageIdsByGroupId, - selectScheduledMessages, + selectChatScheduledMessages, selectScheduledIds, selectCurrentMessageIds, selectChatMessage, selectCurrentMessageList, + selectChat, } from '../selectors'; import { areSortedArraysEqual, omit, pickTruthy, unique, @@ -76,6 +77,13 @@ export function updateThread( ): GlobalState { const current = global.messages.byChatId[chatId]; + if (threadUpdate.listedIds?.length) { + const lastListedId = threadUpdate.listedIds[threadUpdate.listedIds.length - 1]; + if (lastListedId) { + global = updateTopicLastMessageId(global, chatId, threadId, lastListedId); + } + } + return updateMessageStore(global, chatId, { threadsById: { ...(current?.threadsById), @@ -170,7 +178,7 @@ export function updateChatMessage( export function updateScheduledMessage( global: GlobalState, chatId: string, messageId: number, messageUpdate: Partial, ): GlobalState { - const byId = selectScheduledMessages(global, chatId) || {}; + const byId = selectChatScheduledMessages(global, chatId) || {}; const message = byId[messageId]; const updatedMessage = { ...message, @@ -208,32 +216,38 @@ export function deleteChatMessages( let listedIds = selectListedIds(global, chatId, threadId); let outlyingIds = selectOutlyingIds(global, chatId, threadId); let viewportIds = selectViewportIds(global, chatId, threadId); - let pinnedIds = selectPinnedIds(global, chatId); + let pinnedIds = selectPinnedIds(global, chatId, threadId); + let mainPinnedIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID); let newMessageCount = threadInfo?.messagesCount; messageIds.forEach((messageId) => { - if (listedIds && listedIds.includes(messageId)) { + if (listedIds?.includes(messageId)) { listedIds = listedIds.filter((id) => id !== messageId); if (newMessageCount !== undefined) newMessageCount -= 1; } - if (outlyingIds && outlyingIds.includes(messageId)) { + if (outlyingIds?.includes(messageId)) { outlyingIds = outlyingIds.filter((id) => id !== messageId); } - if (viewportIds && viewportIds.includes(messageId)) { + if (viewportIds?.includes(messageId)) { viewportIds = viewportIds.filter((id) => id !== messageId); } - if (pinnedIds && pinnedIds.includes(messageId)) { + if (pinnedIds?.includes(messageId)) { pinnedIds = pinnedIds.filter((id) => id !== messageId); } + + if (mainPinnedIds?.includes(messageId)) { + mainPinnedIds = mainPinnedIds.filter((id) => id !== messageId); + } }); global = replaceThreadParam(global, chatId, threadId, 'listedIds', listedIds); global = replaceThreadParam(global, chatId, threadId, 'outlyingIds', outlyingIds); global = replaceThreadParam(global, chatId, threadId, 'viewportIds', viewportIds); global = replaceThreadParam(global, chatId, threadId, 'pinnedIds', pinnedIds); + global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds', mainPinnedIds); if (threadInfo && newMessageCount !== undefined) { global = replaceThreadParam(global, chatId, threadId, 'threadInfo', { @@ -272,13 +286,13 @@ export function deleteChatScheduledMessages( chatId: string, messageIds: number[], ): GlobalState { - const byId = selectScheduledMessages(global, chatId); + const byId = selectChatScheduledMessages(global, chatId); if (!byId) { return global; } const newById = omit(byId, messageIds); - let scheduledIds = selectScheduledIds(global, chatId); + let scheduledIds = selectScheduledIds(global, chatId, MAIN_THREAD_ID); if (scheduledIds) { messageIds.forEach((messageId) => { if (scheduledIds!.includes(messageId)) { @@ -286,6 +300,13 @@ export function deleteChatScheduledMessages( } }); global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds', scheduledIds); + + Object.entries(global.messages.byChatId[chatId].threadsById).forEach(([threadId, thread]) => { + if (thread.scheduledIds) { + const newScheduledIds = thread.scheduledIds.filter((id) => !messageIds.includes(id)); + global = replaceThreadParam(global, chatId, Number(threadId), 'scheduledIds', newScheduledIds); + } + }); } global = replaceScheduledMessages(global, chatId, newById); @@ -566,3 +587,29 @@ export function updateThreadUnreadFromForwardedMessage( } return global; } + +export function updateTopicLastMessageId( + global: GlobalState, chatId: string, threadId: number, lastMessageId: number, +) { + const chat = selectChat(global, chatId); + if (!chat?.topics?.[threadId]) return global; + return { + ...global, + chats: { + ...global.chats, + byId: { + ...global.chats.byId, + [chatId]: { + ...chat, + topics: { + ...chat.topics, + [threadId]: { + ...chat.topics[threadId], + lastMessageId, + }, + }, + }, + }, + }, + }; +} diff --git a/src/global/selectors/localSearch.ts b/src/global/selectors/localSearch.ts index 08679733c..4afdb307a 100644 --- a/src/global/selectors/localSearch.ts +++ b/src/global/selectors/localSearch.ts @@ -18,10 +18,12 @@ export function selectCurrentTextSearch(global: GlobalState) { } export function selectCurrentMediaSearch(global: GlobalState) { - const { chatId } = selectCurrentMessageList(global) || {}; - if (!chatId) { + const { chatId, threadId } = selectCurrentMessageList(global) || {}; + if (!chatId || !threadId) { return undefined; } - return global.localMediaSearch.byChatId[chatId]; + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return global.localMediaSearch.byChatThreadKey[chatThreadKey]; } diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 11caa50c4..46f717adb 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -1,18 +1,17 @@ import type { GlobalState, MessageListType, Thread } from '../types'; import type { ApiChat, - ApiStickerSetInfo, ApiMessage, ApiMessageEntityCustomEmoji, ApiMessageOutgoingStatus, + ApiStickerSetInfo, ApiUser, } from '../../api/types'; -import { - MAIN_THREAD_ID, - ApiMessageEntityTypes, -} from '../../api/types'; +import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types'; -import { LOCAL_MESSAGE_MIN_ID, REPLIES_USER_ID, SERVICE_NOTIFICATIONS_USER_ID } from '../../config'; +import { + GENERAL_TOPIC_ID, LOCAL_MESSAGE_MIN_ID, REPLIES_USER_ID, SERVICE_NOTIFICATIONS_USER_ID, +} from '../../config'; import { selectChat, selectChatBot, selectIsChatWithBot, selectIsChatWithSelf, } from './chats'; @@ -20,29 +19,29 @@ import { selectIsCurrentUserPremium, selectIsUserOrChatContact, selectUser, selectUserStatus, } from './users'; import { - getSendingState, - isChatChannel, - isMessageLocal, - isUserId, - isForwardedMessage, getCanPostInChat, - isUserRightBanned, getHasAdminRight, - isChatBasicGroup, - isCommonBoxChat, - isServiceNotificationMessage, - isOwnMessage, + getMessageAudio, + getMessageDocument, + getMessageOriginalId, + getMessagePhoto, + getMessageVideo, + getMessageVoice, + getMessageWebPagePhoto, + getMessageWebPageVideo, + getSendingState, isActionMessage, + isChatBasicGroup, + isChatChannel, isChatGroup, isChatSuperGroup, - getMessageVideo, - getMessageWebPageVideo, - getMessagePhoto, - getMessageAudio, - getMessageVoice, - getMessageDocument, - getMessageWebPagePhoto, - getMessageOriginalId, + isCommonBoxChat, + isForwardedMessage, + isMessageLocal, + isOwnMessage, + isServiceNotificationMessage, + isUserId, + isUserRightBanned, canSendReaction, } from '../helpers'; import { findLast } from '../../util/iteratees'; @@ -72,7 +71,7 @@ export function selectChatMessages(global: GlobalState, chatId: string) { return global.messages.byChatId[chatId]?.byId; } -export function selectScheduledMessages(global: GlobalState, chatId: string) { +export function selectChatScheduledMessages(global: GlobalState, chatId: string) { return global.scheduledMessages.byChatId[chatId]?.byId; } @@ -110,9 +109,9 @@ export function selectCurrentMessageIds( case 'thread': return selectViewportIds(global, chatId, threadId); case 'pinned': - return selectPinnedIds(global, chatId); + return selectPinnedIds(global, chatId, threadId); case 'scheduled': - return selectScheduledIds(global, chatId); + return selectScheduledIds(global, chatId, threadId); } return undefined; @@ -122,12 +121,12 @@ export function selectViewportIds(global: GlobalState, chatId: string, threadId: return selectThreadParam(global, chatId, threadId, 'viewportIds'); } -export function selectPinnedIds(global: GlobalState, chatId: string) { - return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds'); +export function selectPinnedIds(global: GlobalState, chatId: string, threadId: number) { + return selectThreadParam(global, chatId, threadId, 'pinnedIds'); } -export function selectScheduledIds(global: GlobalState, chatId: string) { - return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'scheduledIds'); +export function selectScheduledIds(global: GlobalState, chatId: string, threadId: number) { + return selectThreadParam(global, chatId, threadId, 'scheduledIds'); } export function selectScrollOffset(global: GlobalState, chatId: string, threadId: number) { @@ -180,11 +179,8 @@ export function selectThreadOriginChat(global: GlobalState, chatId: string, thre } const threadInfo = selectThreadInfo(global, chatId, threadId); - if (!threadInfo) { - return undefined; - } - return selectChat(global, threadInfo.originChannelId || chatId); + return selectChat(global, threadInfo?.originChannelId || chatId); } export function selectThreadTopMessageId(global: GlobalState, chatId: string, threadId: number) { @@ -192,6 +188,11 @@ export function selectThreadTopMessageId(global: GlobalState, chatId: string, th return undefined; } + const chat = selectChat(global, chatId); + if (chat?.isForum) { + return threadId; + } + const threadInfo = selectThreadInfo(global, chatId, threadId); if (!threadInfo) { return undefined; @@ -200,23 +201,13 @@ export function selectThreadTopMessageId(global: GlobalState, chatId: string, th return threadInfo.topMessageId; } -export function selectThreadByMessage(global: GlobalState, chatId: string, message: ApiMessage) { - const messageInfo = global.messages.byChatId[chatId]; - if (!messageInfo) { +export function selectThreadByMessage(global: GlobalState, message: ApiMessage) { + const threadId = selectThreadIdFromMessage(global, message); + if (!threadId || threadId === MAIN_THREAD_ID) { return undefined; } - const { replyToMessageId, replyToTopMessageId } = message; - if (!replyToMessageId && !replyToTopMessageId) { - return undefined; - } - - return Object.values(messageInfo.threadsById).find((thread) => { - return thread.threadInfo && ( - (replyToMessageId && replyToMessageId === thread.threadInfo.topMessageId) - || (replyToTopMessageId && replyToTopMessageId === thread.threadInfo.topMessageId) - ); - }); + return global.messages.byChatId[message.chatId].threadsById[threadId]; } export function selectIsMessageInCurrentMessageList(global: GlobalState, chatId: string, message: ApiMessage) { @@ -225,7 +216,7 @@ export function selectIsMessageInCurrentMessageList(global: GlobalState, chatId: return false; } - const { threadInfo } = selectThreadByMessage(global, chatId, message) || {}; + const { threadInfo } = selectThreadByMessage(global, message) || {}; return ( chatId === currentMessageList.chatId && ( @@ -274,7 +265,7 @@ export function selectChatMessage(global: GlobalState, chatId: string, messageId } export function selectScheduledMessage(global: GlobalState, chatId: string, messageId: number) { - const chatMessages = selectScheduledMessages(global, chatId); + const chatMessages = selectChatScheduledMessages(global, chatId); return chatMessages ? chatMessages[messageId] : undefined; } @@ -376,6 +367,64 @@ export function selectForwardedSender(global: GlobalState, message: ApiMessage): return undefined; } +const MAX_MESSAGES_TO_DELETE_OWNER_TOPIC = 10; +export function selectCanDeleteOwnerTopic(global: GlobalState, chatId: string, topicId: number) { + const chat = selectChat(global, chatId); + if (!chat) { + return false; + } + + if (chat.topics?.[topicId] && !chat.topics?.[topicId].isOwner) return false; + + const thread = global.messages.byChatId[chatId]?.threadsById[topicId]; + + if (!thread) return false; + + const { listedIds } = thread; + if (!listedIds + // Plus one for root message + || listedIds.length + 1 >= MAX_MESSAGES_TO_DELETE_OWNER_TOPIC) { + return false; + } + + const hasNotOutgoingMessages = listedIds.some((messageId) => { + const message = selectChatMessage(global, chatId, messageId); + return !message || !message.isOutgoing; + }); + + return !hasNotOutgoingMessages; +} + +export function selectCanDeleteTopic(global: GlobalState, chatId: string, topicId: number) { + const chat = selectChat(global, chatId); + if (!chat) return false; + + if (topicId === GENERAL_TOPIC_ID) return false; + + return chat.isCreator + || getHasAdminRight(chat, 'deleteMessages') + || (chat.isForum + && selectCanDeleteOwnerTopic(global, chat.id, topicId)); +} + +export function selectThreadIdFromMessage(global: GlobalState, message: ApiMessage): number { + const chat = selectChat(global, message.chatId); + const { replyToMessageId, replyToTopMessageId, isTopicReply } = message; + // TODO ignore only basic group if reply threads are added + if (!chat?.isForum) return MAIN_THREAD_ID; + if (!isTopicReply) return GENERAL_TOPIC_ID; + return replyToTopMessageId || replyToMessageId || GENERAL_TOPIC_ID; +} + +export function selectTopicFromMessage(global: GlobalState, message: ApiMessage) { + const { chatId } = message; + const chat = selectChat(global, chatId); + if (!chat?.isForum) return undefined; + + const threadId = selectThreadIdFromMessage(global, message); + return chat.topics?.[threadId]; +} + export function selectAllowedMessageActions(global: GlobalState, message: ApiMessage, threadId: number) { const chat = selectChat(global, message.chatId); if (!chat || chat.isRestricted) { @@ -392,6 +441,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes const isOwn = isOwnMessage(message); const isAction = isActionMessage(message); const { content } = message; + const messageTopic = selectTopicFromMessage(global, message); const canEditMessagesIndefinitely = isChatWithSelf || (isSuperGroup && getHasAdminRight(chat, 'pinMessages')) @@ -409,7 +459,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes && !chat.isForbidden ); - const canReply = !isLocal && !isServiceNotification && !chat.isForbidden && getCanPostInChat(chat, threadId); + const canReply = !isLocal && !isServiceNotification && !chat.isForbidden && getCanPostInChat(chat, threadId) + && (!messageTopic || !messageTopic.isClosed || messageTopic.isOwner || getHasAdminRight(chat, 'manageTopics')); const hasPinPermission = isPrivate || ( chat.isCreator @@ -420,7 +471,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes let canPin = !isLocal && !isServiceNotification && !isAction && hasPinPermission; let canUnpin = false; - const pinnedMessageIds = selectPinnedIds(global, chat.id); + const pinnedMessageIds = selectPinnedIds(global, chat.id, threadId); if (canPin) { canUnpin = Boolean(pinnedMessageIds && pinnedMessageIds.includes(message.id)); diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index cd470aec0..58cd32fe9 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -44,3 +44,9 @@ export function selectTheme(global: GlobalState) { return shouldUseSystemTheme ? getSystemTheme() : theme; } + +export function selectIsForumPanelOpen(global: GlobalState) { + return Boolean(global.forumPanelChatId) && ( + global.globalSearch.query === undefined || global.globalSearch.isClosing + ); +} diff --git a/src/global/types.ts b/src/global/types.ts index 6e7a57f06..84574d47c 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -49,6 +49,7 @@ import type { ApiConfig, ApiReaction, ApiChatReactions, + ApiTypingStatus, } from '../api/types'; import type { FocusDirection, @@ -121,6 +122,7 @@ export interface Thread { threadInfo?: ApiThreadInfo; firstMessageId?: number; replyStack?: number[]; + typingStatus?: ApiTypingStatus; } export interface ServiceNotification { @@ -240,6 +242,8 @@ export type GlobalState = { phoneCall?: ApiPhoneCall; ratingPhoneCall?: ApiPhoneCall; + forumPanelChatId?: string; + scheduledMessages: { byChatId: Record; @@ -370,10 +374,12 @@ export type GlobalState = { recentlyFoundChatIds?: string[]; currentContent?: GlobalSearchContent; chatId?: string; + foundTopicIds?: number[]; fetchingStatus?: { chats?: boolean; messages?: boolean; }; + isClosing?: boolean; localResults?: { chatIds?: string[]; userIds?: string[]; @@ -413,7 +419,7 @@ export type GlobalState = { }; localMediaSearch: { - byChatId: Record 0) { + } else if (x - menuRect.width - rootRect.left > 0) { horizontalPosition = 'right'; x -= 3; } else { @@ -97,18 +99,25 @@ export default function useContextMenuPosition( setPositionY(verticalPosition); const triggerRect = triggerEl.getBoundingClientRect(); - const left = horizontalPosition === 'left' - ? Math.min(x - triggerRect.left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX) - : (x - triggerRect.left); - const top = y - triggerRect.top; + + const addedYForPortalPositioning = (shouldUsePortalPositioning ? triggerRect.top : 0); + const addedXForPortalPositioning = (shouldUsePortalPositioning ? triggerRect.left : 0); + + const left = (horizontalPosition === 'left' + ? Math.max(MENU_POSITION_VISUAL_COMFORT_SPACE_PX, Math.min( + x - triggerRect.left, + rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX, + )) + : (x - triggerRect.left)) + addedXForPortalPositioning; + const top = y - triggerRect.top + addedYForPortalPositioning; const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0); setWithScroll(menuMaxHeight < menuRect.height); setMenuStyle(`max-height: ${menuMaxHeight}px;`); setStyle(`left: ${left}px; top: ${top}px`); - const offsetX = (anchorX - triggerRect.left) - left; - const offsetY = (anchorY - triggerRect.top) - top - (marginTop || 0); + const offsetX = (anchorX + addedXForPortalPositioning - triggerRect.left) - left; + const offsetY = (anchorY + addedYForPortalPositioning - triggerRect.top) - top - (marginTop || 0); setTransformOriginX(horizontalPosition === 'left' ? offsetX : menuRect.width + offsetX); setTransformOriginY(verticalPosition === 'bottom' ? menuRect.height + offsetY : offsetY); }, [ diff --git a/src/hooks/useFlag.ts b/src/hooks/useFlag.ts index a8fef9486..7acd11b6c 100644 --- a/src/hooks/useFlag.ts +++ b/src/hooks/useFlag.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from '../lib/teact/teact'; -const useFlag = (initial = false): [boolean, AnyToVoidFunction, AnyToVoidFunction] => { +const useFlag = (initial = false): [boolean, NoneToVoidFunction, NoneToVoidFunction] => { const [value, setValue] = useState(initial); const setTrue = useCallback(() => { diff --git a/src/hooks/useForumPanelRender.ts b/src/hooks/useForumPanelRender.ts new file mode 100644 index 000000000..bf0a739c7 --- /dev/null +++ b/src/hooks/useForumPanelRender.ts @@ -0,0 +1,25 @@ +import { useCallback, useRef } from '../lib/teact/teact'; + +import useForceUpdate from './useForceUpdate'; +import useOnChange from './useOnChange'; + +export default function useForumPanelRender(isForumPanelOpen = false) { + const shouldRenderForumPanelRef = useRef(isForumPanelOpen); + const forceUpdate = useForceUpdate(); + + useOnChange(() => { + if (isForumPanelOpen) { + shouldRenderForumPanelRef.current = true; + } + }, [isForumPanelOpen]); + + const handleForumPanelAnimationEnd = useCallback(() => { + shouldRenderForumPanelRef.current = false; + forceUpdate(); + }, [forceUpdate]); + + return { + shouldRenderForumPanel: shouldRenderForumPanelRef.current, + handleForumPanelAnimationEnd, + }; +} diff --git a/src/hooks/useInputFocusOnOpen.ts b/src/hooks/useInputFocusOnOpen.ts index 6a0937264..7fd4c70b6 100644 --- a/src/hooks/useInputFocusOnOpen.ts +++ b/src/hooks/useInputFocusOnOpen.ts @@ -16,14 +16,14 @@ export default function useInputFocusOnOpen( if (!IS_SINGLE_COLUMN_LAYOUT) { setTimeout(() => { requestAnimationFrame(() => { - if (inputRef.current) { + if (inputRef.current?.isConnected) { inputRef.current.focus(); } }); }, FOCUS_DELAY_MS); } } else { - if (inputRef.current) { + if (inputRef.current?.isConnected) { inputRef.current.blur(); } diff --git a/src/hooks/useResize.ts b/src/hooks/useResize.ts index b97f4e6ef..510646152 100644 --- a/src/hooks/useResize.ts +++ b/src/hooks/useResize.ts @@ -1,5 +1,7 @@ import type { RefObject } from 'react'; -import { useState, useEffect, useLayoutEffect } from '../lib/teact/teact'; +import { + useState, useEffect, useLayoutEffect, useCallback, +} from '../lib/teact/teact'; import useFlag from './useFlag'; export function useResize( @@ -7,18 +9,31 @@ export function useResize( onResize: (width: number) => void, onReset: NoneToVoidFunction, initialWidth?: number, + cssPropertyName?: string, ) { const [isActive, markIsActive, unmarkIsActive] = useFlag(); const [initialMouseX, setInitialMouseX] = useState(); const [initialElementWidth, setInitialElementWidth] = useState(); + const setElementStyle = useCallback((width?: number) => { + if (!elementRef.current) { + return; + } + + const widthPx = width ? `${width}px` : ''; + elementRef.current.style.width = widthPx; + if (cssPropertyName) { + elementRef.current.style.setProperty(cssPropertyName, widthPx); + } + }, [cssPropertyName, elementRef]); + useLayoutEffect(() => { if (!elementRef.current || !initialWidth) { return; } - elementRef.current.style.width = `${initialWidth}px`; - }, [elementRef, initialWidth]); + setElementStyle(initialWidth); + }, [cssPropertyName, elementRef, initialWidth, setElementStyle]); function handleMouseUp() { document.body.classList.remove('cursor-ew-resize'); @@ -36,7 +51,7 @@ export function useResize( function resetResize(e: React.MouseEvent) { e.preventDefault(); - elementRef.current!.style.width = ''; + setElementStyle(undefined); onReset(); } @@ -45,7 +60,7 @@ export function useResize( const handleMouseMove = (e: MouseEvent) => { const newWidth = Math.ceil(initialElementWidth + e.clientX - initialMouseX); - elementRef.current!.style.width = `${newWidth}px`; + setElementStyle(newWidth); }; function stopDrag() { @@ -66,7 +81,7 @@ export function useResize( document.addEventListener('blur', stopDrag, false); return cleanup; - }, [initialElementWidth, initialMouseX, elementRef, onResize, isActive, unmarkIsActive]); + }, [initialElementWidth, initialMouseX, elementRef, onResize, isActive, unmarkIsActive, setElementStyle]); return { initResize, resetResize, handleMouseUp }; } diff --git a/src/lib/gramjs/client/MockClient.ts b/src/lib/gramjs/client/MockClient.ts index 4f8c0e833..3606f5457 100644 --- a/src/lib/gramjs/client/MockClient.ts +++ b/src/lib/gramjs/client/MockClient.ts @@ -1,288 +1,245 @@ import BigInt from 'big-integer'; +import type { MockTypes } from './mockUtils/MockTypes'; +import type { DownloadFileParams } from './downloadFile'; + import { UpdateConnectionState } from '../network'; import Api from '../tl/api'; +import createMockedUser from './mockUtils/createMockedUser'; +import createMockedDialog from './mockUtils/createMockedDialog'; +import createMockedChannel from './mockUtils/createMockedChannel'; +import createMockedChat from './mockUtils/createMockedChat'; +import createMockedMessage from './mockUtils/createMockedMessage'; +import getIdFromInputPeer from './mockUtils/getIdFromInputPeer'; +import createMockedAvailableReaction from './mockUtils/createMockedAvailableReaction'; +import MockSender from './MockSender'; +import { downloadFile } from './downloadFile'; +import getDocumentIdFromLocation from './mockUtils/getDocumentIdFromLocation'; +import createMockedDialogFilter from './mockUtils/createMockedDialogFilter'; +import createMockedTypePeer from './mockUtils/createMockedTypePeer'; +import createMockedForumTopic from './mockUtils/createMockedForumTopic'; +import { GENERAL_TOPIC_ID } from '../../../config'; +import createMockedJSON from './mockUtils/createMockedJSON'; -type Peer = { - peer: Api.Chat | Api.Channel | Api.User; - inputPeer: Api.TypePeer; - TEST_messages: Api.Message[]; - TEST_sendMessage: (data: CreateMessageParams) => Api.Message | undefined; -}; - -type CreateMessageParams = { - fromId?: any; - repliesChannelId?: any; - replyingTo?: Api.MessageReplyHeader; -}; +const sizeTypes = ['u', 'v', 'w', 'y', 'd', 'x', 'c', 'm', 'b', 'a', 's', 'f']; class TelegramClient { - addEventHandler(callback: any, event: any) { - callback(event.build(new UpdateConnectionState(UpdateConnectionState.connected))); - } + private invokeMiddleware?: (mockClient: TelegramClient, request: Api.Request) + => Promise; - private lastId = 0; + private mockData: MockTypes = { + users: [], + chats: [], + channels: [], + dialogFilters: [], + dialogs: { + active: [], + archived: [], + }, + messages: {}, + availableReactions: [], + documents: [], + topPeers: [], + }; - private peers: Peer[] = []; - - private dialogs: Api.Dialog[] = []; - - start() { - } + private _log: {}; constructor() { - const user = this.createUser({ - firstName: 'Test', - lastName: 'Account', - }); - user.TEST_sendMessage({}); - - const chat = this.createChat(); - chat.TEST_sendMessage({}); - - const channel = this.createChannel({ - title: 'Test Channel', - username: 'testchannel', - }); - - const discussion = this.createChannel({ - title: 'Test Discussion', - username: 'testdiscuss', - isMegagroup: true, - }); - - const message = channel.TEST_sendMessage({ - repliesChannelId: discussion.peer.id, - }); - - const { id } = discussion.TEST_sendMessage({})!; - - discussion.TEST_sendMessage({ - fromId: new Api.PeerUser({ - userId: user.peer.id, - }), - replyingTo: new Api.MessageReplyHeader({ - replyToMsgId: id, - replyToPeerId: new Api.PeerChannel({ - channelId: channel.peer.id, - }), - replyToTopId: message!.id, - }), - }); - } - - createDialog(peer: Api.TypePeer) { - return new Api.Dialog({ - peer, - topMessage: 0, - readInboxMaxId: 0, - readOutboxMaxId: 0, - unreadCount: 0, - unreadMentionsCount: 0, - unreadReactionsCount: 0, - notifySettings: new Api.PeerNotifySettings({}), - }); - } - - createMessage(peer: Api.TypePeer) { - return ({ - fromId, - repliesChannelId, - replyingTo, - }: CreateMessageParams) => { - const pi = this.getPeerIndex(peer); - const p = this.getPeer(peer); - if (!p || pi === undefined) return undefined; - - const message = new Api.Message({ - id: p.TEST_messages.length + 1, - fromId, - peerId: peer, - date: Number(new Date()) / 1000 + pi * 60, - message: 'lol @channel', - entities: [new Api.MessageEntityMention({ - offset: 4, - length: 8, - })], - replyTo: replyingTo, - replies: new Api.MessageReplies({ - comments: true, - replies: 0, - repliesPts: 0, - channelId: repliesChannelId ? BigInt(repliesChannelId) : undefined, - }), - }); - this.peers[pi].TEST_messages.push(message); - return message; + this._log = { + info: () => {}, }; } - createChat() { - const chat = new Api.Chat({ - id: BigInt(this.lastId++), - title: 'Some chat', - photo: new Api.ChatPhotoEmpty(), - participantsCount: 1, - date: 1000, - version: 1, + private callbacks: { + callback: any; + eventBuilder: any; + }[] = []; + + addEventHandler(callback: any, eventBuilder: any) { + this.callbacks.push({ + callback, + eventBuilder, }); - - const peerChat = new Api.PeerChat({ - chatId: chat.id, - }); - - this.dialogs.push(this.createDialog(peerChat)); - - const testChat: Peer = { - peer: chat, inputPeer: peerChat, TEST_messages: [], TEST_sendMessage: this.createMessage(peerChat), - }; - - this.peers.push(testChat); - - return testChat; } - createChannel({ title, username, isMegagroup }: { - title: string; - username: string; - isMegagroup?: boolean; - }) { - const channel = new Api.Channel({ - username, - id: BigInt(this.lastId++), - megagroup: isMegagroup ? true : undefined, - title, - photo: new Api.ChatPhotoEmpty(), - participantsCount: 1, - date: 1000, - creator: true, - }); + async loadScenario(scenario = 'default'): Promise { + try { + const invokeMiddleware = await import(`./__invokeMiddlewares__/${scenario}`); - const peerChannel = new Api.PeerChannel({ - channelId: channel.id, - }); + this.invokeMiddleware = invokeMiddleware.default; + } catch (e) { + // Ignore and use the default logic + } + return import(`./__mocks__/${scenario}.json`).then(async (mockData) => { + this.mockData = mockData as MockTypes; + await Promise.all(this.mockData.documents.map(async (l, i) => { + const response = await import(`./__data__/${l.url}`).then((module) => fetch(module.default)); + const bytes = await response.arrayBuffer(); + this.mockData.documents[i].size = BigInt(bytes.byteLength); + this.mockData.documents[i].bytes = Buffer.from(new Uint8Array(bytes)); + })); - this.dialogs.push(this.createDialog(peerChannel)); - - const testChat: Peer = { - peer: channel, inputPeer: peerChannel, TEST_messages: [], TEST_sendMessage: this.createMessage(peerChannel), - }; - - this.peers.push(testChat); - - return testChat; + this.callbacks.forEach(({ eventBuilder, callback }) => (callback( + eventBuilder.build(new UpdateConnectionState(UpdateConnectionState.connected)), + ))); + }).catch(() => this.loadScenario()); } - createUser({ - firstName, - lastName, + fireUpdate(update: Api.TypeUpdate) { + this.callbacks.forEach(({ eventBuilder, callback }) => (callback(eventBuilder.build(update)))); + } + + getUser(id: string) { + return createMockedUser(id, this.mockData); + } + + getDialogs(type: 'active' | 'archived' = 'active') { + return this.mockData.dialogs[type].map((dialog) => createMockedDialog(dialog, this.mockData)); + } + + start({ + mockScenario, }: { - firstName: string; - lastName: string; - }): Peer { - const user = new Api.User({ - // self: true, - verified: true, - id: BigInt(this.lastId++), - // accessHash?: long; - firstName, - lastName, - username: 'man', - // phone?: string; - // photo?: Api.TypeUserProfilePhoto; - // status?: Api.TypeUserStatus; - // botInfoVersion?: int; - // restrictionReason?: Api.//TypeRestrictionReason[]; - // botInlinePlaceholder?: string; - // langCode?: string; - }); - - const peerUser = new Api.PeerUser({ - userId: user.id, - }); - - this.dialogs.push(this.createDialog(peerUser)); - - const testChat: Peer = { - peer: user, inputPeer: peerUser, TEST_messages: [], TEST_sendMessage: this.createMessage(peerUser), - }; - - this.peers.push(testChat); - - return testChat; + mockScenario: string; + }) { + return this.loadScenario(mockScenario); } - invoke(request: any) { - // await new Promise(resolve => setTimeout(resolve, 1000)) + async invoke(request: Api.Request) { + if (this.invokeMiddleware) { + const a = await this.invokeMiddleware(this, request); + if (a !== 'pass') { + return a; + } + } + + if (this.mockData.appConfig && request instanceof Api.help.GetAppConfig) { + return createMockedJSON(this.mockData.appConfig); + } + if (request instanceof Api.messages.GetDiscussionMessage) { + const peerId = getIdFromInputPeer(request.peer); + if (!peerId) return undefined; + return new Api.messages.DiscussionMessage({ - messages: [ - this.peers[3].TEST_messages[0], - ], - maxId: 2, - unreadCount: 1, + messages: this.getMessagesFrom(peerId).filter((l) => l.id === request.msgId), + unreadCount: 0, chats: [], users: [], }); } - if (request instanceof Api.messages.GetHistory) { - const peer = this.getPeer(request.peer); - if (!peer) return undefined; - return new Api.messages.Messages({ - messages: peer.TEST_messages, - chats: [], - users: [], - }); - } if (request instanceof Api.messages.GetReplies) { - const peer = this.peers[3]; - if (!peer) return undefined; + const peerId = getIdFromInputPeer(request.peer); + if (!peerId) return undefined; - return new Api.messages.ChannelMessages({ - messages: peer.TEST_messages, - topics: [], - pts: 0, - count: peer.TEST_messages.length, + const messages = this.mockData.messages[peerId].filter((message) => message.replyToTopId === request.msgId); + return new Api.messages.Messages({ + messages: messages.map((message) => createMockedMessage(peerId, message.id, this.mockData)), chats: [], users: [], }); } - if (request instanceof Api.messages.GetDialogFilters) { - return [new Api.DialogFilter({ - contacts: true, - nonContacts: true, - groups: true, - broadcasts: true, - bots: true, - // excludeMuted?: true; - // excludeRead?: true; - // excludeArchived?: true; - id: 1, - title: 'Dialog Filter', - // emoticon?: string; - pinnedPeers: [], - includePeers: [], - excludePeers: [], - })]; - } + if (request instanceof Api.contacts.GetTopPeers) { return new Api.contacts.TopPeers({ categories: [new Api.TopPeerCategoryPeers({ category: new Api.TopPeerCategoryCorrespondents(), - count: 1, - peers: [ - new Api.TopPeer({ - peer: this.peers[0].inputPeer, + count: this.mockData.topPeers.length, + peers: this.mockData.topPeers.map((id) => { + return new Api.TopPeer({ + peer: createMockedTypePeer(id, this.mockData), rating: 100, - }), - ], + }); + }), })], chats: [], - users: [ - this.getUsers()[0], - ], + users: this.getUsers(), }); } + + if (request instanceof Api.channels.GetForumTopics) { + const channelId = getIdFromInputPeer(request.channel); + if (!channelId) return undefined; + + const topics = this.getChannel(channelId)?.forumTopics; + + if (!topics) return undefined; + + const hasGeneralTopic = topics.some((l) => l.id === GENERAL_TOPIC_ID); + const offsetTopicId = request.offsetTopic; + const limit = request.limit; + return new Api.messages.ForumTopics({ + topics: topics + .sort((a, b) => b.id - a.id) + .map((topic) => { + return createMockedForumTopic(channelId, topic.id, this.mockData); + }).filter((topic) => { + if (offsetTopicId) { + return topic.id < offsetTopicId; + } + return true; + }).filter((_, i) => i < limit), + users: [], + chats: [], + messages: [], + pts: 0, + count: topics.length - (hasGeneralTopic ? 1 : 0), + }); + } + + if (request instanceof Api.users.GetFullUser) { + return new Api.users.UserFull({ + fullUser: new Api.UserFull({ + about: 'lol', + settings: new Api.PeerSettings({}), + notifySettings: new Api.PeerNotifySettings({}), + id: BigInt(1), + commonChatsCount: 0, + }), + chats: [], + users: [], + }); + } + + if (request instanceof Api.messages.GetAvailableReactions) { + return new Api.messages.AvailableReactions({ + reactions: this.mockData.availableReactions.map((reaction) => { + return createMockedAvailableReaction(reaction, this.mockData); + }), + hash: 1, + }); + } + + if (request instanceof Api.messages.GetHistory) { + const peerId = getIdFromInputPeer(request.peer); + if (!peerId) return undefined; + + return new Api.messages.Messages({ + messages: this.getMessagesFrom(peerId), + chats: [], + users: [], + }); + } + + if (request instanceof Api.upload.GetFile) { + const fileId = getDocumentIdFromLocation(request.location); + if (fileId === undefined) return undefined; + + return new Api.upload.File({ + type: new Api.storage.FileUnknown(), + mtime: 0, + bytes: Buffer.from(new Uint8Array(this.mockData.documents.find((i) => i.id === fileId)!.bytes)), + }); + } + + if (request instanceof Api.messages.GetDialogFilters) { + return [ + new Api.DialogFilterDefault(), + ...this.mockData.dialogFilters + .map((dialogFilter) => createMockedDialogFilter(dialogFilter.id, this.mockData)), + ]; + } + if (request instanceof Api.messages.GetPinnedDialogs) { return new Api.messages.PeerDialogs({ dialogs: [], @@ -298,6 +255,7 @@ class TelegramClient { }), }); } + if (request instanceof Api.messages.GetDialogs) { if (request.folderId || !(request.offsetPeer instanceof Api.InputPeerEmpty)) { return new Api.messages.Dialogs({ @@ -309,48 +267,171 @@ class TelegramClient { } return new Api.messages.Dialogs({ - dialogs: this.dialogs, + dialogs: this.getDialogs(), messages: this.getAllMessages(), - chats: this.getChats(), + chats: this.getChatsAndChannels(), users: this.getUsers(), }); } return undefined; - // console.log(request.className, request); } - private getPeerIndex(peer: Api.TypeInputPeer) { - const id = 'channelId' in peer ? peer.channelId : ( - 'userId' in peer ? peer.userId : ( - 'chatId' in peer ? peer.chatId : undefined - ) + public getSender() { + return new MockSender(this); + } + + downloadFile(inputLocation: any, args: DownloadFileParams) { + return downloadFile(this as any, inputLocation, args); + } + + _downloadPhoto(photo: Api.MessageMediaPhoto | Api.Photo | undefined, args: any) { + if (photo instanceof Api.MessageMediaPhoto) { + photo = photo.photo; + } + if (!(photo instanceof Api.Photo)) { + return undefined; + } + const isVideoSize = args.sizeType === 'u' || args.sizeType === 'v'; + const size = this._pickFileSize(isVideoSize + ? [...(photo.videoSizes as any), ...photo.sizes] + : photo.sizes, args.sizeType); + if (!size || (size instanceof Api.PhotoSizeEmpty)) { + return undefined; + } + + if (size instanceof Api.PhotoCachedSize || size instanceof Api.PhotoStrippedSize) { + // TODO[mock] Implement + // return this._downloadCachedPhotoSize(size); + return undefined; + } + return this.downloadFile( + new Api.InputPhotoFileLocation({ + id: photo.id, + accessHash: photo.accessHash, + fileReference: photo.fileReference, + thumbSize: size.type, + }), + { + dcId: photo.dcId, + fileSize: size.size || Math.max(...(size.sizes || [])), + progressCallback: args.progressCallback, + }, ); - - if (!id) return undefined; - - return this.peers.findIndex((localPeer) => localPeer.peer.id.toString() === id.toString()); } - private getPeer(peer: Api.TypeInputPeer) { - const index = this.getPeerIndex(peer); - if (index === undefined) return undefined; + downloadMedia(messageOrMedia: any, args: any) { + let media; + if (messageOrMedia instanceof Api.Message) { + media = messageOrMedia.media; + } else { + media = messageOrMedia; + } + if (typeof media === 'string') { + throw new Error('not implemented'); + } - return this.peers[index]; + if (media instanceof Api.MessageMediaWebPage) { + if (media.webpage instanceof Api.WebPage) { + media = media.webpage.document || media.webpage.photo; + } + } + if (media instanceof Api.MessageMediaPhoto || media instanceof Api.Photo) { + return this._downloadPhoto(media, args); + } else if (media instanceof Api.MessageMediaDocument || media instanceof Api.Document) { + return this._downloadDocument(media, args); + } else if (media instanceof Api.MessageMediaContact) { + return undefined; + } else if (media instanceof Api.WebDocument || media instanceof Api.WebDocumentNoProxy) { + return undefined; + } + return undefined; + } + + _downloadDocument(doc: any, args: any) { + if (doc instanceof Api.MessageMediaDocument) { + doc = doc.document; + } + if (!(doc instanceof Api.Document)) { + return undefined; + } + + let size; + if (args.sizeType) { + size = doc.thumbs ? this._pickFileSize([...(doc.videoThumbs || []), + ...doc.thumbs], args.sizeType) : undefined; + if (!size && doc.mimeType.startsWith('video/')) { + return undefined; + } + + if (size && (size instanceof Api.PhotoCachedSize + || size instanceof Api.PhotoStrippedSize)) { + // TODO[mock] Implement + // return this._downloadCachedPhotoSize(size); + return undefined; + } + } + + return this.downloadFile( + new Api.InputDocumentFileLocation({ + id: doc.id, + accessHash: doc.accessHash, + fileReference: doc.fileReference, + thumbSize: size ? size.type : '', + }), + { + fileSize: size ? size.size : doc.size.toJSNumber(), + progressCallback: args.progressCallback, + start: args.start, + end: args.end, + dcId: doc.dcId, + workers: args.workers, + }, + ); + } + + _pickFileSize(sizes: any, sizeType: any) { + if (!sizeType || !sizes || !sizes.length) { + return undefined; + } + const indexOfSize = sizeTypes.indexOf(sizeType); + let size; + for (let i = indexOfSize; i < sizeTypes.length; i++) { + size = sizes.find((s: any) => s.type === sizeTypes[i]); + if (size) { + return size; + } + } + return undefined; + } + + private getMessagesFrom(chatId: string) { + return this.mockData.messages[chatId].map((message) => createMockedMessage(chatId, message.id, this.mockData)); } private getAllMessages() { - return this.peers.reduce((acc: Api.Message[], el) => { - acc.push(...el.TEST_messages); - return acc; - }, []); + return Object.entries(this.mockData.messages).flatMap(([chatId, messages]) => { + return messages.map((message) => createMockedMessage(chatId, message.id, this.mockData)); + }); + } + + private getChatsAndChannels() { + return [...this.getChannels(), ...this.getChats()]; } private getChats() { - return this.peers.filter(({ peer }) => !(peer instanceof Api.User)).map(({ peer }) => peer); + return this.mockData.chats.map((chat) => createMockedChat(chat.id, this.mockData)); + } + + private getChannel(chatId: string) { + return this.mockData.channels.find((channel) => channel.id === chatId); + } + + private getChannels() { + return this.mockData.channels.map((channel) => createMockedChannel(channel.id, this.mockData)); } private getUsers() { - return this.peers.filter(({ peer }) => peer instanceof Api.User).map(({ peer }) => peer); + return this.mockData.users.map((user) => createMockedUser(user.id, this.mockData)); } } diff --git a/src/lib/gramjs/client/MockSender.ts b/src/lib/gramjs/client/MockSender.ts new file mode 100644 index 000000000..e3d5b09bd --- /dev/null +++ b/src/lib/gramjs/client/MockSender.ts @@ -0,0 +1,14 @@ +import type MockClient from './MockClient'; + +export default class MockSender { + constructor(private mockClient: MockClient) { + } + + send(request: any) { + return this.mockClient.invoke(request); + } + + isConnected() { + return true; + } +} diff --git a/src/lib/gramjs/client/__data__/Cumshot.tgs b/src/lib/gramjs/client/__data__/Cumshot.tgs new file mode 100644 index 0000000000000000000000000000000000000000..2f316b92da0d633d10f0cc5a26ed1666b164db7b GIT binary patch literal 64906 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 literal 0 HcmV?d00001 diff --git a/src/lib/gramjs/client/__data__/Peach.tgs b/src/lib/gramjs/client/__data__/Peach.tgs new file mode 100644 index 0000000000000000000000000000000000000000..78d40f9f32cfff19a52841ed003eebce4af7b3e9 GIT binary patch literal 30301 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 Aa{vGU literal 0 HcmV?d00001 diff --git a/src/lib/gramjs/client/__data__/lock.png b/src/lib/gramjs/client/__data__/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..a9789c1f7f03de10c2c44c297fb8195165fff910 GIT binary patch literal 5631 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 literal 0 HcmV?d00001 diff --git a/src/lib/gramjs/client/__invokeMiddlewares__/forums/no-topics.ts b/src/lib/gramjs/client/__invokeMiddlewares__/forums/no-topics.ts new file mode 100644 index 000000000..64ef13a9c --- /dev/null +++ b/src/lib/gramjs/client/__invokeMiddlewares__/forums/no-topics.ts @@ -0,0 +1,9 @@ +import Api from "../../../tl/api"; +import TelegramClient from "../../MockClient"; + +export default async function(mockClient: TelegramClient, request: Api.Request) { + if(request instanceof Api.channels.GetForumTopics) { + await new Promise((resolve) => setTimeout(resolve, 2500)); + } + return "pass"; +} diff --git a/src/lib/gramjs/client/__mocks__/default.json b/src/lib/gramjs/client/__mocks__/default.json new file mode 100644 index 000000000..79ecfaaaa --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/default.json @@ -0,0 +1,118 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "scam": true, + "verified": true, + "title": "aass" + } + ], + "dialogs": { + "active": [{ + "id": "1" + }, + { + "id": "2" + }], + "archived": [] + }, + "messages": { + "1": [ + { + "id": 1, + "message": "Hello world!", + "media": { + "type": "document", + "id": 1 + } + }, + { + "id": 2, + "message": "Hello world 2!", + "media": { + "type": "photo", + "id": 1 + } + } + ], + "2": [ + { + "id": 3, + "message": "Hello channel!", + "reactions": { + "results": [ + { + "emoticon": "a", + "count": 10 + }, + { + "emoticon": "b", + "count": 20 + } + ] + }, + "replies": { + "replies": 1 + } + }, + { + "id": 4, + "message": "Hello channel 2!" + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/can-delete-messages.json b/src/lib/gramjs/client/__mocks__/forums/can-delete-messages.json new file mode 100644 index 000000000..d6cb8b0e1 --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/can-delete-messages.json @@ -0,0 +1,94 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "adminRights": { + "deleteMessages": true + }, + "forumTopics": [ + { + "id": 2, + "topMessage": 3, + "title": "My Topic" + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 2, + "message": "Hello channel!" + }, + { + "id": 3, + "message": "Hello channel 2!" + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/can-manage-topics.json b/src/lib/gramjs/client/__mocks__/forums/can-manage-topics.json new file mode 100644 index 000000000..3c7473d05 --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/can-manage-topics.json @@ -0,0 +1,94 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "adminRights": { + "manageTopics": true + }, + "forumTopics": [ + { + "id": 2, + "topMessage": 3, + "title": "My Topic" + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 2, + "message": "Hello channel!" + }, + { + "id": 3, + "message": "Hello channel 2!" + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/default.json b/src/lib/gramjs/client/__mocks__/forums/default.json new file mode 100644 index 000000000..2b4cebe8f --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/default.json @@ -0,0 +1,93 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "forumTopics": [ + { + "id": 2, + "topMessage": 3, + "title": "My Topic" + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 2, + "message": "Hello channel!" + }, + { + "id": 3, + "message": "Hello channel 2!", + "replyToTopId": 1, + "replyToForumTopic": true + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/no-topics.json b/src/lib/gramjs/client/__mocks__/forums/no-topics.json new file mode 100644 index 000000000..6760e81e8 --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/no-topics.json @@ -0,0 +1,74 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "forumTopics": [] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": {}, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/owner-of-topic-messages-lots.json b/src/lib/gramjs/client/__mocks__/forums/owner-of-topic-messages-lots.json new file mode 100644 index 000000000..497269ead --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/owner-of-topic-messages-lots.json @@ -0,0 +1,159 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "forumTopics": [ + { + "id": 2, + "topMessage": 12, + "title": "My Topic", + "my": true + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 2, + "message": "Hello channel!", + "out": true + }, + { + "id": 3, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 4, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 5, + "message": "Hello channel!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 6, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 7, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 8, + "message": "Hello channel!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 9, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 10, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 11, + "message": "Hello channel!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 12, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/owner-of-topic-not-outgoing-messages.json b/src/lib/gramjs/client/__mocks__/forums/owner-of-topic-not-outgoing-messages.json new file mode 100644 index 000000000..52b0574ea --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/owner-of-topic-not-outgoing-messages.json @@ -0,0 +1,101 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "forumTopics": [ + { + "id": 2, + "topMessage": 4, + "title": "My Topic", + "my": true + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 2, + "message": "Hello channel!", + "out": true + }, + { + "id": 3, + "message": "Hello channel 2!", + "replyToTopId": 2, + "replyToForumTopic": true + }, + { + "id": 4, + "message": "Hello channel 2!", + "replyToTopId": 2, + "replyToForumTopic": true + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/owner-of-topic.json b/src/lib/gramjs/client/__mocks__/forums/owner-of-topic.json new file mode 100644 index 000000000..512dffd40 --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/owner-of-topic.json @@ -0,0 +1,96 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "forumTopics": [ + { + "id": 2, + "topMessage": 3, + "title": "My Topic", + "my": true + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 2, + "message": "Hello channel!", + "out": true + }, + { + "id": 3, + "message": "Hello channel 2!", + "out": true, + "replyToTopId": 2, + "replyToForumTopic": true + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/owner.json b/src/lib/gramjs/client/__mocks__/forums/owner.json new file mode 100644 index 000000000..4d1eae968 --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/owner.json @@ -0,0 +1,99 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "creator": true, + "forumTopics": [ + { + "id": 1, + "topMessage": 3, + "title": "General Topic" + }, + { + "id": 2, + "topMessage": 3, + "title": "My Topic" + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 2, + "message": "Hello channel!" + }, + { + "id": 3, + "message": "Hello channel 2!", + "replyToTopId": 2, + "replyToForumTopic": true + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/pinned-a-lot.json b/src/lib/gramjs/client/__mocks__/forums/pinned-a-lot.json new file mode 100644 index 000000000..f1692b552 --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/pinned-a-lot.json @@ -0,0 +1,116 @@ +{ + "appConfig": { + "topics_pinned_limit": 3 + }, + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "adminRights": { + "manageTopics": true + }, + "forumTopics": [ + { + "id": 1, + "topMessage": 2, + "title": "My Topic", + "pinned": true + }, + { + "id": 2, + "topMessage": 2, + "title": "My Topic 2", + "pinned": true + }, + { + "id": 3, + "topMessage": 2, + "title": "My Topic 3", + "pinned": true + }, + { + "id": 4, + "topMessage": 2, + "title": "The Unpinned", + "pinned": false + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 1, + "message": "Hello channel!" + }, + { + "id": 2, + "message": "Hello channel 2!" + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/forums/topics-a-lot.json b/src/lib/gramjs/client/__mocks__/forums/topics-a-lot.json new file mode 100644 index 000000000..16c412b5d --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/forums/topics-a-lot.json @@ -0,0 +1,199 @@ +{ + "users": [ + { + "id": "1", + "self": true + } + ], + "chats": [ + ], + "channels": [ + { + "id": "2", + "title": "The Forum", + "forum": true, + "megagroup": true, + "adminRights": { + "manageTopics": true + }, + "forumTopics": [ + { + "id": 1, + "topMessage": 2, + "title": "My Topic" + }, + { + "id": 2, + "topMessage": 2, + "title": "My Topic 2" + }, + { + "id": 3, + "topMessage": 2, + "title": "My Topic 3" + }, + { + "id": 4, + "topMessage": 2, + "title": "My Topic 4" + }, + { + "id": 5, + "topMessage": 2, + "title": "My Topic 5" + }, + { + "id": 6, + "topMessage": 2, + "title": "My Topic 6" + }, + { + "id": 7, + "topMessage": 2, + "title": "My Topic 7" + }, + { + "id": 8, + "topMessage": 2, + "title": "My Topic 8" + }, + { + "id": 9, + "topMessage": 2, + "title": "My Topic 9" + }, + { + "id": 10, + "topMessage": 2, + "title": "My Topic 10" + }, + { + "id": 11, + "topMessage": 2, + "title": "My Topic 11" + }, + { + "id": 12, + "topMessage": 2, + "title": "My Topic 12" + }, + { + "id": 13, + "topMessage": 2, + "title": "My Topic 13" + }, + { + "id": 14, + "topMessage": 2, + "title": "My Topic 14" + }, + { + "id": 15, + "topMessage": 2, + "title": "My Topic 15" + }, + { + "id": 16, + "topMessage": 2, + "title": "My Topic 16" + }, + { + "id": 17, + "topMessage": 2, + "title": "My Topic 17" + }, + { + "id": 18, + "topMessage": 2, + "title": "My Topic 18" + }, + { + "id": 19, + "topMessage": 2, + "title": "My Topic 19" + }, + { + "id": 20, + "topMessage": 2, + "title": "My Topic 20" + }, + { + "id": 21, + "topMessage": 2, + "title": "My Topic 21" + }, + { + "id": 22, + "topMessage": 2, + "title": "My Topic 22" + } + ] + } + ], + "dialogs": { + "active": [ + { + "id": "2" + } + ], + "archived": [] + }, + "messages": { + "2": [ + { + "id": 1, + "message": "Hello channel!" + }, + { + "id": 2, + "message": "Hello channel 2!" + } + ] + }, + "availableReactions": [ + { + "reaction": "a", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 2, + "effectId": 4 + }, + { + "reaction": "b", + "title": "Some Reaction", + "staticIconId": 1, + "animationId": 3, + "effectId": 4 + } + ], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Chats" + } + ], + "documents": [ + { + "id": 1, + "url": "lock.png", + "mimeType": "image/png" + }, + { + "id": 2, + "url": "Eggplant.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 3, + "url": "Peach.tgs", + "mimeType": "application/x-tgsticker" + }, + { + "id": 4, + "url": "Cumshot.tgs", + "mimeType": "application/x-tgsticker" + } + ] +} diff --git a/src/lib/gramjs/client/__mocks__/history.json b/src/lib/gramjs/client/__mocks__/history.json new file mode 100644 index 000000000..c6fa3ebdc --- /dev/null +++ b/src/lib/gramjs/client/__mocks__/history.json @@ -0,0 +1,104 @@ +{ + "users": [ + { + "id": "1", + "self": true + }, + { + "id": "5", + "firstName": "Test", + "lastName": "Account", + "verified": true + } + ], + "chats": [ + { + "id": "4", + "title": "Some chat" + } + ], + "channels": [ + { + "id": "3", + "title": "Test Discussion" + }, + { + "id": "2", + "title": "Test Channel" + } + ], + "dialogs": { + "active": [ + { + "id": "2" + }, + { + "id": "3" + }, + { + "id": "4" + }, + { + "id": "5" + } + ], + "archived": [] + }, + "messages": { + "3": [ + { + "id": 1, + "message": "Hello world!" + }, + { + "id": 2, + "message": "Hello world 2!" + } + ], + "2": [ + { + "id": 3, + "message": "Hello channel!", + "replies": { + "replies": 1 + } + }, + { + "id": 4, + "message": "Hello channel 2!" + } + ], + + "4": [ + { + "id": 5, + "message": "Hello world!" + }, + { + "id": 6, + "message": "Hello world 2!" + } + ], + "5": [ + { + "id": 7, + "message": "Hello world!" + }, + { + "id": 8, + "message": "Hello world 2!" + } + ] + }, + "availableReactions": [], + "dialogFilters": [ + { + "groups": true, + "broadcasts": true, + "id": 1, + "title": "Dialog Filter" + } + ], + "documents": [], + "topPeers": ["5"] +} diff --git a/src/lib/gramjs/client/auth.ts b/src/lib/gramjs/client/auth.ts index ecb7d17be..9a44ca19c 100644 --- a/src/lib/gramjs/client/auth.ts +++ b/src/lib/gramjs/client/auth.ts @@ -16,6 +16,7 @@ export interface UserAuthParams { initialMethod?: 'phoneNumber' | 'qrCode'; shouldThrowIfUnauthorized?: boolean; webAuthToken?: string; + mockScenario?: string; } export interface BotAuthParams { diff --git a/src/lib/gramjs/client/mockUtils/MockTypes.ts b/src/lib/gramjs/client/mockUtils/MockTypes.ts new file mode 100644 index 000000000..268d43339 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/MockTypes.ts @@ -0,0 +1,93 @@ +import type bigInt from 'big-integer'; +import type Api from '../../tl/api'; +import type { ApiAvailableReaction } from '../../../../api/types'; +import type { GramJsAppConfig } from '../../../../api/gramjs/apiBuilders/appConfig'; + +export type MockDialog = { + id: string; +}; + +export type MockUser = Partial & { + id: string; +}; + +export type MockChat = Partial & { + id: string; +}; + +export type MockForumTopic = Partial & { + id: number; + topMessage: number; +}; + +export type MockChannel = Partial & { + id: string; + title: string; + forumTopics?: MockForumTopic[]; + bannedRights?: MockBannedRights; + adminRights?: MockAdminRights; +}; + +export type MockAdminRights = Api.ChatAdminRights; +export type MockBannedRights = Partial; + +export type MockMessage = Omit, 'reactions'> & { + id: number; + media?: MockMessageMedia; + reactions?: MockMessageReactions; + replyToTopId?: number; + replyToMsgId?: number; + replyToForumTopic?: boolean; +}; + +export type MockMessageMedia = { + type: 'document' | 'photo'; + id: number; +}; + +export type MockAvailableReaction = Pick & { + staticIconId: number; + animationId: number; + effectId: number; +}; + +export type MockMessageReactions = { + results: { + emoticon: string; + count: number; + }[]; +}; + +export type MockDocument = Partial & { + id: number; + mimeType: string; + size: bigInt.BigInteger; + url: string; + bytes: Buffer; +}; + +export type MockDialogFilter = Partial & { + id: number; + pinnedPeerIds: string[]; + includePeerIds: string[]; + excludePeerIds: string[]; + title: string; +}; + +export type MockTypes = { + appConfig?: GramJsAppConfig; + users: MockUser[]; + chats: MockChat[]; + channels: MockChannel[]; + dialogs: { + active: MockDialog[]; + archived: MockDialog[]; + }; + messages: Record; + availableReactions: MockAvailableReaction[]; + documents: MockDocument[]; + dialogFilters: MockDialogFilter[]; + topPeers: string[]; +}; + +export const MOCK_STARTING_DATE = 1_66_69_69_420; diff --git a/src/lib/gramjs/client/mockUtils/createMockedAvailableReaction.ts b/src/lib/gramjs/client/mockUtils/createMockedAvailableReaction.ts new file mode 100644 index 000000000..c055a942a --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedAvailableReaction.ts @@ -0,0 +1,27 @@ +import Api from '../../tl/api'; +import createMockedDocument from './createMockedDocument'; +import type { MockAvailableReaction, MockTypes } from './MockTypes'; + +export default function createMockedAvailableReaction( + mockAvailableReaction: MockAvailableReaction, mockData: MockTypes, +) { + const { + staticIconId, + animationId, + effectId, + reaction, + ...rest + } = mockAvailableReaction; + return new Api.AvailableReaction({ + ...rest, + staticIcon: createMockedDocument(staticIconId, mockData), + centerIcon: createMockedDocument(animationId, mockData), + selectAnimation: createMockedDocument(animationId, mockData), + aroundAnimation: createMockedDocument(effectId, mockData), + reaction: reaction.emoticon, + // Not used yet + appearAnimation: createMockedDocument(animationId, mockData), + activateAnimation: createMockedDocument(animationId, mockData), + effectAnimation: createMockedDocument(animationId, mockData), + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedChannel.ts b/src/lib/gramjs/client/mockUtils/createMockedChannel.ts new file mode 100644 index 000000000..10219a417 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedChannel.ts @@ -0,0 +1,31 @@ +import Api from '../../tl/api'; +import BigInt from "big-integer"; +import {MOCK_STARTING_DATE, MockTypes} from "./MockTypes"; +import createMockedChatBannedRights from "./createMockedChatBannedRights"; +import createMockedChatAdminRights from "./createMockedChatAdminRights"; + +export default function createMockedChannel(id: string, mockData: MockTypes): Api.Channel { + const channel = mockData.channels.find((channel) => channel.id === id); + + if(!channel) throw Error("No such channel " + id); + + const { + accessHash = BigInt(1), + title = "Channel", + date = MOCK_STARTING_DATE, + bannedRights = createMockedChatBannedRights(id, mockData), + adminRights = createMockedChatAdminRights(id, mockData), + ...rest + } = channel; + + return new Api.Channel({ + ...rest, + id: BigInt(Number(id) + 1000000000), + accessHash, + title, + bannedRights, + adminRights, + photo: new Api.ChatPhotoEmpty(), + date, + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedChat.ts b/src/lib/gramjs/client/mockUtils/createMockedChat.ts new file mode 100644 index 000000000..5891a82a4 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedChat.ts @@ -0,0 +1,27 @@ +import Api from '../../tl/api'; +import BigInt from "big-integer"; +import {MOCK_STARTING_DATE, MockTypes} from "./MockTypes"; + +export default function createMockedChat(id: string, mockData: MockTypes): Api.Chat { + const chat = mockData.chats.find((chat) => chat.id === id); + + if(!chat) throw Error("No such chat " + id); + + const { + title = "Chat", + participantsCount = 1, + version = 0, + date = MOCK_STARTING_DATE, + ...rest + } = chat; + + return new Api.Chat({ + ...rest, + id: BigInt(id), + title, + photo: new Api.ChatPhotoEmpty(), + participantsCount, + date, + version, + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedChatAdminRights.ts b/src/lib/gramjs/client/mockUtils/createMockedChatAdminRights.ts new file mode 100644 index 000000000..1d3aa26c4 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedChatAdminRights.ts @@ -0,0 +1,16 @@ +import {MockTypes} from "./MockTypes"; +import Api from "../../tl/api"; + +export default function createMockedChatAdminRights(chatId: string, mockData: MockTypes) { + const channel = mockData.channels.find((channel) => channel.id === chatId); + + if(!channel) throw Error("No such channel " + chatId); + + const { + adminRights, + } = channel; + + return new Api.ChatAdminRights({ + ...adminRights, + }) +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedChatBannedRights.ts b/src/lib/gramjs/client/mockUtils/createMockedChatBannedRights.ts new file mode 100644 index 000000000..594afe6f6 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedChatBannedRights.ts @@ -0,0 +1,17 @@ +import {MockTypes} from "./MockTypes"; +import Api from "../../tl/api"; + +export default function createMockedChatBannedRights(chatId: string, mockData: MockTypes) { + const channel = mockData.channels.find((channel) => channel.id === chatId); + + if(!channel) throw Error("No such channel " + chatId); + + const { + bannedRights, + } = channel; + + return new Api.ChatBannedRights({ + ...bannedRights, + untilDate: 0, + }) +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedDialog.ts b/src/lib/gramjs/client/mockUtils/createMockedDialog.ts new file mode 100644 index 000000000..367a063b9 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedDialog.ts @@ -0,0 +1,18 @@ +import Api from "../../tl/api"; +import createMockedTypePeer from "./createMockedTypePeer"; +import {MockDialog, MockTypes} from "./MockTypes"; + +export default function createMockedDialog({ + id, +}: MockDialog, mockData: MockTypes): Api.Dialog { + return new Api.Dialog({ + peer: createMockedTypePeer(id, mockData), + topMessage: 0, + readInboxMaxId: 0, + readOutboxMaxId: 0, + unreadCount: 0, + unreadMentionsCount: 0, + unreadReactionsCount: 0, + notifySettings: new Api.PeerNotifySettings({}), + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedDialogFilter.ts b/src/lib/gramjs/client/mockUtils/createMockedDialogFilter.ts new file mode 100644 index 000000000..61ee59be1 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedDialogFilter.ts @@ -0,0 +1,24 @@ +import Api from "../../tl/api"; +import {MockTypes} from "./MockTypes"; +import createMockedTypeInputPeer from "./createMockedTypeInputPeer"; + +export default function createMockedDialogFilter(id: number, mockData: MockTypes) { + const dialogFilter = mockData.dialogFilters.find(dialogFilter => dialogFilter.id === id); + + if(!dialogFilter) throw Error("No such dialog filter " + id); + + const { + includePeerIds = [], + pinnedPeerIds = [], + excludePeerIds = [], + ...rest + } = dialogFilter; + + return new Api.DialogFilter({ + ...rest, + id, + includePeers: includePeerIds.map((peer) => createMockedTypeInputPeer(peer, mockData)), + pinnedPeers: pinnedPeerIds.map((peer) => createMockedTypeInputPeer(peer, mockData)), + excludePeers: excludePeerIds.map((peer) => createMockedTypeInputPeer(peer, mockData)), + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedDocument.ts b/src/lib/gramjs/client/mockUtils/createMockedDocument.ts new file mode 100644 index 000000000..2a1e1817e --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedDocument.ts @@ -0,0 +1,30 @@ +import Api from "../../tl/api"; +import BigInt from "big-integer"; +import {MOCK_STARTING_DATE, MockTypes} from "./MockTypes"; + +export default function createMockedDocument(documentId: number, mockData: MockTypes): Api.Document { + const document = mockData.documents.find(doc => doc.id === documentId); + + if(!document) throw Error("No such document " + documentId); + + const { + accessHash = BigInt(1), + fileReference = Buffer.from([0]), + date = MOCK_STARTING_DATE, + dcId = 2, + url, + ...rest + } = document; + + return new Api.Document({ + ...rest, + id: BigInt(documentId), + accessHash, + fileReference, + date, + // thumbs?: Api.TypePhotoSize[]; + // videoThumbs?: Api.TypeVideoSize[]; + dcId, + attributes: [],//Api.TypeDocumentAttribute[]; + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedForumTopic.ts b/src/lib/gramjs/client/mockUtils/createMockedForumTopic.ts new file mode 100644 index 000000000..eac279464 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedForumTopic.ts @@ -0,0 +1,41 @@ +import type { MockTypes } from './MockTypes'; +import { MOCK_STARTING_DATE } from './MockTypes'; +import Api from '../../tl/api'; +import createMockedTypePeer from './createMockedTypePeer'; + +export default function createMockedForumTopic(chatId: string, topicId: number, mockData: MockTypes) { + const channel = mockData.channels.find((c) => c.id === chatId); + + if (!channel) throw Error(`No such channel ${chatId}`); + + const forumTopic = channel.forumTopics?.find((ft) => ft.id === topicId); + + if (!forumTopic) throw Error(`No such forum topic ${topicId}`); + + const { + notifySettings = new Api.PeerNotifySettings({}), + date = MOCK_STARTING_DATE, + title = 'Topic', + iconColor = 0x6FB9F0, + readInboxMaxId = 0, + readOutboxMaxId = 0, + unreadCount = 0, + unreadMentionsCount = 0, + unreadReactionsCount = 0, + ...rest + } = forumTopic; + + return new Api.ForumTopic({ + notifySettings, + date, + title, + iconColor, + readInboxMaxId, + readOutboxMaxId, + unreadCount, + unreadMentionsCount, + unreadReactionsCount, + fromId: createMockedTypePeer(chatId, mockData), + ...rest, + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedJSON.ts b/src/lib/gramjs/client/mockUtils/createMockedJSON.ts new file mode 100644 index 000000000..c5155a8e6 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedJSON.ts @@ -0,0 +1,38 @@ +import Api from '../../tl/api'; + +export default function createMockedJSON(data: any): Api.TypeJSONValue { + if (!data) { + return new Api.JsonNull(); + } + + if (Array.isArray(data)) { + return new Api.JsonArray({ + value: data.map(createMockedJSON), + }); + } + + if (typeof data === 'string') { + return new Api.JsonString({ + value: data, + }); + } + + if (typeof data === 'number') { + return new Api.JsonNumber({ + value: data, + }); + } + + if (typeof data === 'boolean') { + return new Api.JsonBool({ + value: data, + }); + } + + return new Api.JsonObject({ + value: Object.entries(data).map(([key, value]) => (new Api.JsonObjectValue({ + key, + value: createMockedJSON(value), + }))), + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedMessage.ts b/src/lib/gramjs/client/mockUtils/createMockedMessage.ts new file mode 100644 index 000000000..a66a52e93 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedMessage.ts @@ -0,0 +1,36 @@ +import Api from '../../tl/api'; +import {MOCK_STARTING_DATE, MockTypes} from "./MockTypes"; +import createMockedTypePeer from "./createMockedTypePeer"; +import createMockedMessageMedia from "./createMockedMessageMedia"; +import createMockedMessageReactions from "./createMockedMessageReactions"; +import createMockedReplies from "./createMockedReplies"; +import createMockedReplyTo from "./createMockedReplyTo"; +import {omit} from "../../../../util/iteratees"; + +export default function createMockedMessage(chatId: string, id: number, mockData: MockTypes): Api.Message { + const msg = mockData.messages[chatId].find((message) => message.id === id); + + if(!msg) throw Error("No such message " + id); + + const { + date = MOCK_STARTING_DATE + id, + message = "Message", + media, + reactions, + replies, + replyTo = createMockedReplyTo(chatId, id, mockData), + ...rest + } = omit(msg, ['replyToMsgId', 'replyToTopId', 'replyToForumTopic']); + + return new Api.Message({ + ...rest, + id, + peerId: createMockedTypePeer(chatId, mockData), + date, + message, + replyTo, + ...(media ? { media: createMockedMessageMedia(media, mockData) } : undefined), + ...(reactions ? { reactions: createMockedMessageReactions(chatId, id, mockData) } : undefined), + ...(replies ? { replies: createMockedReplies(chatId, id, mockData) } : undefined), + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedMessageMedia.ts b/src/lib/gramjs/client/mockUtils/createMockedMessageMedia.ts new file mode 100644 index 000000000..39b2f4263 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedMessageMedia.ts @@ -0,0 +1,19 @@ +import Api from "../../tl/api"; +import {MockMessageMedia, MockTypes} from "./MockTypes"; +import createMockedDocument from "./createMockedDocument"; +import createMockedPhoto from "./createMockedPhoto"; + +export default function createMockedMessageMedia(media: MockMessageMedia, mockData: MockTypes): Api.TypeMessageMedia { + if(media.type === "document") { + return new Api.MessageMediaDocument({ + document: createMockedDocument(media.id, mockData) + }) + } + + if(media.type === 'photo') { + return new Api.MessageMediaPhoto({ + photo: createMockedPhoto(media.id, mockData) + }); + } + throw Error("Unsupported media: " + media.type) +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedMessageReactions.ts b/src/lib/gramjs/client/mockUtils/createMockedMessageReactions.ts new file mode 100644 index 000000000..5754463a1 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedMessageReactions.ts @@ -0,0 +1,25 @@ +import Api from "../../tl/api"; +import {MockMessageReactions, MockTypes} from "./MockTypes"; + +export default function createMockedMessageReactions(chatId: string, id: number, mockData: MockTypes) { + const msg = mockData.messages[chatId].find((message) => message.id === id); + + if(!msg) throw Error("No such message " + id); + + const { + reactions, + } = msg; + + if(!reactions) throw Error("No reactions on message " + id); + + return new Api.MessageReactions({ + results: reactions.results.map((r) => new Api.ReactionCount({ + reaction: new Api.ReactionEmoji({ + emoticon: r.emoticon + }), + count: r.count, + })), + recentReactions: [], + canSeeList: true, + }) +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedPhoto.ts b/src/lib/gramjs/client/mockUtils/createMockedPhoto.ts new file mode 100644 index 000000000..fa1968e02 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedPhoto.ts @@ -0,0 +1,44 @@ +import Api from "../../tl/api"; +import BigInt from "big-integer"; +import {MOCK_STARTING_DATE, MockTypes} from "./MockTypes"; + +export default function createMockedPhoto(documentId: number, mockData: MockTypes): Api.Photo { + const document = mockData.documents.find(doc => doc.id === documentId); + + if(!document) throw Error("No such document " + documentId); + + const { + accessHash = BigInt(1), + fileReference = Buffer.from([0]), + date = MOCK_STARTING_DATE, + dcId = 2, + url, + size, + ...rest + } = document; + + return new Api.Photo({ + ...rest, + id: BigInt(documentId), + accessHash, + fileReference, + date, + sizes: [ + new Api.PhotoSize({ + type: "m", + w: 100, + h: 100, + size: size.toJSNumber() + }), + new Api.PhotoSize({ + type: "x", + w: 100, + h: 100, + size: size.toJSNumber() + }) + ], + // thumbs?: Api.TypePhotoSize[]; + // videoThumbs?: Api.TypeVideoSize[]; + dcId, + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedReplies.ts b/src/lib/gramjs/client/mockUtils/createMockedReplies.ts new file mode 100644 index 000000000..a9e33b7cc --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedReplies.ts @@ -0,0 +1,23 @@ +import BigInt from 'big-integer'; +import Api from '../../tl/api'; +import type { MockTypes } from './MockTypes'; + +export default function createMockedReplies(chatId: string, id: number, mockData: MockTypes) { + const msg = mockData.messages[chatId].find((message) => message.id === id); + + if (!msg) throw Error(`No such message ${id}`); + + const { + replies, + } = msg; + + if (!replies) throw Error(`No replies on message ${id}`); + + return new Api.MessageReplies({ + comments: true, + replies: replies.replies, + repliesPts: 1, + channelId: BigInt(1000000000 + 2), + // recentRepliers?: Api.TypePeer[]; + }); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedReplyTo.ts b/src/lib/gramjs/client/mockUtils/createMockedReplyTo.ts new file mode 100644 index 000000000..c599a4902 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedReplyTo.ts @@ -0,0 +1,22 @@ +import {MockTypes} from "./MockTypes"; +import Api from "../../tl/api"; + +export default function createMockedReplyTo(chatId: string, messageId: number, mockData: MockTypes) { + const msg = mockData.messages[chatId].find((message) => message.id === messageId); + + if (!msg) throw Error("No such message " + messageId); + + const { + replyToTopId, + replyToMsgId, + replyToForumTopic, + } = msg; + + if(!replyToMsgId || !replyToTopId) return undefined; + + return new Api.MessageReplyHeader({ + replyToTopId, + replyToMsgId, + ...(replyToForumTopic && { forumTopic: replyToForumTopic }) + }) +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedTypeInputPeer.ts b/src/lib/gramjs/client/mockUtils/createMockedTypeInputPeer.ts new file mode 100644 index 000000000..210b02252 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedTypeInputPeer.ts @@ -0,0 +1,30 @@ +import Api from "../../tl/api"; +import BigInt from "big-integer"; +import {MockTypes} from "./MockTypes"; + +export default function createMockedTypeInputPeer(id: string, mockData: MockTypes): Api.TypeInputPeer { + const user = mockData.users.find((user) => user.id === id); + if(user) { + return new Api.InputPeerUser({ + userId: BigInt(id), + accessHash: BigInt(1), + }) + } + + const chat = mockData.chats.find((chat) => chat.id === id); + if(chat) { + return new Api.InputPeerChat({ + chatId: BigInt(id), + }) + } + + const channel = mockData.channels.find((channel) => channel.id === id); + if(channel) { + return new Api.InputPeerChannel({ + channelId: BigInt(Number(id) + 1000000000), + accessHash: BigInt(1), + }) + } + + throw Error("No such peer " + id); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedTypePeer.ts b/src/lib/gramjs/client/mockUtils/createMockedTypePeer.ts new file mode 100644 index 000000000..8829d2dc9 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedTypePeer.ts @@ -0,0 +1,28 @@ +import Api from "../../tl/api"; +import BigInt from "big-integer"; +import {MockTypes} from "./MockTypes"; + +export default function createMockedTypePeer(id: string, mockData: MockTypes): Api.TypePeer { + const user = mockData.users.find((user) => user.id === id); + if(user) { + return new Api.PeerUser({ + userId: BigInt(id), + }) + } + + const chat = mockData.chats.find((chat) => chat.id === id); + if(chat) { + return new Api.PeerChat({ + chatId: BigInt(id), + }) + } + + const channel = mockData.channels.find((channel) => channel.id === id); + if(channel) { + return new Api.PeerChannel({ + channelId: BigInt(Number(id) + 1000000000), + }) + } + + throw Error("No such peer " + id); +} diff --git a/src/lib/gramjs/client/mockUtils/createMockedUser.ts b/src/lib/gramjs/client/mockUtils/createMockedUser.ts new file mode 100644 index 000000000..394914f8d --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/createMockedUser.ts @@ -0,0 +1,24 @@ +import Api from '../../tl/api'; +import BigInt from "big-integer"; +import {MockTypes} from "./MockTypes"; + +export default function createMockedUser(id: string, mockData: MockTypes): Api.User { + const user = mockData.users.find((user) => user.id === id); + + if(!user) throw Error("No such user " + id); + + const { + firstName = "John", + lastName = "Doe", + accessHash = BigInt(1), + ...rest + } = user; + + return new Api.User({ + ...rest, + id: BigInt(id), + firstName, + lastName, + accessHash, + }); +} diff --git a/src/lib/gramjs/client/mockUtils/getDocumentIdFromLocation.ts b/src/lib/gramjs/client/mockUtils/getDocumentIdFromLocation.ts new file mode 100644 index 000000000..e47f20258 --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/getDocumentIdFromLocation.ts @@ -0,0 +1,13 @@ +import Api from "../../tl/api"; + +export default function getDocumentIdFromLocation(location: Api.TypeInputFileLocation): number { + if(location instanceof Api.InputDocumentFileLocation) { + return location.id.toJSNumber(); + } + + if(location instanceof Api.InputPhotoFileLocation){ + return location.id.toJSNumber(); + } + + throw Error("Unsupported input file location type " + location.className) +} diff --git a/src/lib/gramjs/client/mockUtils/getIdFromInputPeer.ts b/src/lib/gramjs/client/mockUtils/getIdFromInputPeer.ts new file mode 100644 index 000000000..fe96253fa --- /dev/null +++ b/src/lib/gramjs/client/mockUtils/getIdFromInputPeer.ts @@ -0,0 +1,17 @@ +import Api from "../../tl/api"; + +export default function getIdFromInputPeer(peer: Api.TypeInputPeer | Api.TypeInputChannel) { + if(peer instanceof Api.InputPeerChannel || peer instanceof Api.InputChannel) { + return (Number(peer.channelId.toString()) - 1000000000).toString(); + } + + if(peer instanceof Api.InputPeerUser) { + return peer.userId.toString(); + } + + if(peer instanceof Api.InputPeerChat) { + return peer.chatId.toString(); + } + + throw Error("Unknown peer type" + peer.className) +} diff --git a/src/lib/gramjs/tl/AllTLObjects.js b/src/lib/gramjs/tl/AllTLObjects.js index 1b1efad88..e72f602d7 100644 --- a/src/lib/gramjs/tl/AllTLObjects.js +++ b/src/lib/gramjs/tl/AllTLObjects.js @@ -1,6 +1,6 @@ const api = require('./api'); -const LAYER = 149; +const LAYER = 150; const tlobjects = {}; for (const tl of Object.values(api)) { diff --git a/src/lib/gramjs/tl/api.d.ts b/src/lib/gramjs/tl/api.d.ts index 95371eea3..403216951 100644 --- a/src/lib/gramjs/tl/api.d.ts +++ b/src/lib/gramjs/tl/api.d.ts @@ -168,7 +168,7 @@ namespace Api { export type TypeLangPackString = LangPackString | LangPackStringPluralized | LangPackStringDeleted; export type TypeLangPackDifference = LangPackDifference; export type TypeLangPackLanguage = LangPackLanguage; - export type TypeChannelAdminLogEventAction = ChannelAdminLogEventActionChangeTitle | ChannelAdminLogEventActionChangeAbout | ChannelAdminLogEventActionChangeUsername | ChannelAdminLogEventActionChangePhoto | ChannelAdminLogEventActionToggleInvites | ChannelAdminLogEventActionToggleSignatures | ChannelAdminLogEventActionUpdatePinned | ChannelAdminLogEventActionEditMessage | ChannelAdminLogEventActionDeleteMessage | ChannelAdminLogEventActionParticipantJoin | ChannelAdminLogEventActionParticipantLeave | ChannelAdminLogEventActionParticipantInvite | ChannelAdminLogEventActionParticipantToggleBan | ChannelAdminLogEventActionParticipantToggleAdmin | ChannelAdminLogEventActionChangeStickerSet | ChannelAdminLogEventActionTogglePreHistoryHidden | ChannelAdminLogEventActionDefaultBannedRights | ChannelAdminLogEventActionStopPoll | ChannelAdminLogEventActionChangeLinkedChat | ChannelAdminLogEventActionChangeLocation | ChannelAdminLogEventActionToggleSlowMode | ChannelAdminLogEventActionStartGroupCall | ChannelAdminLogEventActionDiscardGroupCall | ChannelAdminLogEventActionParticipantMute | ChannelAdminLogEventActionParticipantUnmute | ChannelAdminLogEventActionToggleGroupCallSetting | ChannelAdminLogEventActionParticipantJoinByInvite | ChannelAdminLogEventActionExportedInviteDelete | ChannelAdminLogEventActionExportedInviteRevoke | ChannelAdminLogEventActionExportedInviteEdit | ChannelAdminLogEventActionParticipantVolume | ChannelAdminLogEventActionChangeHistoryTTL | ChannelAdminLogEventActionParticipantJoinByRequest | ChannelAdminLogEventActionToggleNoForwards | ChannelAdminLogEventActionSendMessage | ChannelAdminLogEventActionChangeAvailableReactions | ChannelAdminLogEventActionChangeUsernames | ChannelAdminLogEventActionToggleForum | ChannelAdminLogEventActionCreateTopic | ChannelAdminLogEventActionEditTopic | ChannelAdminLogEventActionDeleteTopic | ChannelAdminLogEventActionPinTopic; + export type TypeChannelAdminLogEventAction = ChannelAdminLogEventActionChangeTitle | ChannelAdminLogEventActionChangeAbout | ChannelAdminLogEventActionChangeUsername | ChannelAdminLogEventActionChangePhoto | ChannelAdminLogEventActionToggleInvites | ChannelAdminLogEventActionToggleSignatures | ChannelAdminLogEventActionUpdatePinned | ChannelAdminLogEventActionEditMessage | ChannelAdminLogEventActionDeleteMessage | ChannelAdminLogEventActionParticipantJoin | ChannelAdminLogEventActionParticipantLeave | ChannelAdminLogEventActionParticipantInvite | ChannelAdminLogEventActionParticipantToggleBan | ChannelAdminLogEventActionParticipantToggleAdmin | ChannelAdminLogEventActionChangeStickerSet | ChannelAdminLogEventActionTogglePreHistoryHidden | ChannelAdminLogEventActionDefaultBannedRights | ChannelAdminLogEventActionStopPoll | ChannelAdminLogEventActionChangeLinkedChat | ChannelAdminLogEventActionChangeLocation | ChannelAdminLogEventActionToggleSlowMode | ChannelAdminLogEventActionStartGroupCall | ChannelAdminLogEventActionDiscardGroupCall | ChannelAdminLogEventActionParticipantMute | ChannelAdminLogEventActionParticipantUnmute | ChannelAdminLogEventActionToggleGroupCallSetting | ChannelAdminLogEventActionParticipantJoinByInvite | ChannelAdminLogEventActionExportedInviteDelete | ChannelAdminLogEventActionExportedInviteRevoke | ChannelAdminLogEventActionExportedInviteEdit | ChannelAdminLogEventActionParticipantVolume | ChannelAdminLogEventActionChangeHistoryTTL | ChannelAdminLogEventActionParticipantJoinByRequest | ChannelAdminLogEventActionToggleNoForwards | ChannelAdminLogEventActionSendMessage | ChannelAdminLogEventActionChangeAvailableReactions | ChannelAdminLogEventActionChangeUsernames | ChannelAdminLogEventActionToggleForum | ChannelAdminLogEventActionCreateTopic | ChannelAdminLogEventActionEditTopic | ChannelAdminLogEventActionDeleteTopic | ChannelAdminLogEventActionPinTopic | ChannelAdminLogEventActionToggleAntiSpam; export type TypeChannelAdminLogEvent = ChannelAdminLogEvent; export type TypeChannelAdminLogEventsFilter = ChannelAdminLogEventsFilter; export type TypePopularContact = PopularContact; @@ -296,6 +296,8 @@ namespace Api { export type TypeStickerKeyword = StickerKeyword; export type TypeUsername = Username; export type TypeForumTopic = ForumTopicDeleted | ForumTopic; + export type TypeDefaultHistoryTTL = DefaultHistoryTTL; + export type TypeExportedContactToken = ExportedContactToken; export type TypeResPQ = ResPQ; export type TypeP_Q_inner_data = PQInnerData | PQInnerDataDc | PQInnerDataTemp | PQInnerDataTempDc; export type TypeServer_DH_Params = ServerDHParamsFail | ServerDHParamsOk; @@ -333,8 +335,8 @@ namespace Api { export type TypeAuthorization = auth.Authorization | auth.AuthorizationSignUpRequired; export type TypeExportedAuthorization = auth.ExportedAuthorization; export type TypePasswordRecovery = auth.PasswordRecovery; - export type TypeCodeType = auth.CodeTypeSms | auth.CodeTypeCall | auth.CodeTypeFlashCall | auth.CodeTypeMissedCall; - export type TypeSentCodeType = auth.SentCodeTypeApp | auth.SentCodeTypeSms | auth.SentCodeTypeCall | auth.SentCodeTypeFlashCall | auth.SentCodeTypeMissedCall | auth.SentCodeTypeEmailCode | auth.SentCodeTypeSetUpEmailRequired; + export type TypeCodeType = auth.CodeTypeSms | auth.CodeTypeCall | auth.CodeTypeFlashCall | auth.CodeTypeMissedCall | auth.CodeTypeFragmentSms; + export type TypeSentCodeType = auth.SentCodeTypeApp | auth.SentCodeTypeSms | auth.SentCodeTypeCall | auth.SentCodeTypeFlashCall | auth.SentCodeTypeMissedCall | auth.SentCodeTypeEmailCode | auth.SentCodeTypeSetUpEmailRequired | auth.SentCodeTypeFragmentSms; export type TypeLoginToken = auth.LoginToken | auth.LoginTokenMigrateTo | auth.LoginTokenSuccess; export type TypeLoggedOut = auth.LoggedOut; } @@ -1189,6 +1191,7 @@ namespace Api { blocked?: true; // flags2: undefined; canDeleteChannel?: true; + antispam?: true; id: long; about: string; participantsCount?: int; @@ -1236,6 +1239,7 @@ namespace Api { blocked?: true; // flags2: undefined; canDeleteChannel?: true; + antispam?: true; id: long; about: string; participantsCount?: int; @@ -1708,9 +1712,13 @@ namespace Api { users: long[]; }; export class MessageActionSetMessagesTTL extends VirtualClass<{ + // flags: undefined; period: int; + autoSettingFrom?: long; }> { + // flags: undefined; period: int; + autoSettingFrom?: long; }; export class MessageActionGroupCallScheduled extends VirtualClass<{ call: Api.TypeInputGroupCall; @@ -1762,11 +1770,13 @@ namespace Api { title?: string; iconEmojiId?: long; closed?: Bool; + hidden?: Bool; } | void> { // flags: undefined; title?: string; iconEmojiId?: long; closed?: Bool; + hidden?: Bool; }; export class Dialog extends VirtualClass<{ // flags: undefined; @@ -1783,6 +1793,7 @@ namespace Api { pts?: int; draft?: Api.TypeDraftMessage; folderId?: int; + ttlPeriod?: int; }> { // flags: undefined; pinned?: true; @@ -1798,6 +1809,7 @@ namespace Api { pts?: int; draft?: Api.TypeDraftMessage; folderId?: int; + ttlPeriod?: int; }; export class DialogFolder extends VirtualClass<{ // flags: undefined; @@ -5939,6 +5951,11 @@ namespace Api { prevTopic?: Api.TypeForumTopic; newTopic?: Api.TypeForumTopic; }; + export class ChannelAdminLogEventActionToggleAntiSpam extends VirtualClass<{ + newValue: Bool; + }> { + newValue: Bool; + }; export class ChannelAdminLogEvent extends VirtualClass<{ id: long; date: int; @@ -7749,6 +7766,7 @@ namespace Api { closed?: true; pinned?: true; short?: true; + hidden?: true; id: int; date: int; title: string; @@ -7769,6 +7787,7 @@ namespace Api { closed?: true; pinned?: true; short?: true; + hidden?: true; id: int; date: int; title: string; @@ -7784,6 +7803,18 @@ namespace Api { notifySettings: Api.TypePeerNotifySettings; draft?: Api.TypeDraftMessage; }; + export class DefaultHistoryTTL extends VirtualClass<{ + period: int; + }> { + period: int; + }; + export class ExportedContactToken extends VirtualClass<{ + url: string; + expires: int; + }> { + url: string; + expires: int; + }; export class ResPQ extends VirtualClass<{ nonce: int128; serverNonce: int128; @@ -8198,6 +8229,7 @@ namespace Api { export class CodeTypeCall extends VirtualClass {}; export class CodeTypeFlashCall extends VirtualClass {}; export class CodeTypeMissedCall extends VirtualClass {}; + export class CodeTypeFragmentSms extends VirtualClass {}; export class SentCodeTypeApp extends VirtualClass<{ length: int; }> { @@ -8249,6 +8281,13 @@ namespace Api { appleSigninAllowed?: true; googleSigninAllowed?: true; }; + export class SentCodeTypeFragmentSms extends VirtualClass<{ + url: string; + length: int; + }> { + url: string; + length: int; + }; export class LoginToken extends VirtualClass<{ expires: int; token: bytes; @@ -10759,6 +10798,12 @@ namespace Api { }>, contacts.TypeResolvedPeer> { phone: string; }; + export class ExportContactToken extends Request {}; + export class ImportContactToken extends Request, Api.TypeUser> { + token: string; + }; } export namespace messages { @@ -11052,11 +11097,15 @@ namespace Api { userId: Api.TypeInputUser; }; export class CreateChat extends Request, Api.TypeUpdates> { + // flags: undefined; users: Api.TypeInputUser[]; title: string; + ttlPeriod?: int; }; export class GetDhConfig extends Request, Bool> { + period: int; + }; + export class GetDefaultHistoryTTL extends Request {}; } export namespace updates { @@ -12793,6 +12848,7 @@ namespace Api { about: string; geoPoint?: Api.TypeInputGeoPoint; address?: string; + ttlPeriod?: int; }>, Api.TypeUpdates> { // flags: undefined; broadcast?: true; @@ -12802,6 +12858,7 @@ namespace Api { about: string; geoPoint?: Api.TypeInputGeoPoint; address?: string; + ttlPeriod?: int; }; export class EditAdmin extends Request, Api.TypeUpdates> { // flags: undefined; channel: Api.TypeInputChannel; @@ -13118,6 +13176,7 @@ namespace Api { title?: string; iconEmojiId?: long; closed?: Bool; + hidden?: Bool; }; export class UpdatePinnedForumTopic extends Request, Api.TypeUpdates> { + channel: Api.TypeInputChannel; + enabled: Bool; + }; + export class ReportAntiSpamFalsePositive extends Request, Bool> { + channel: Api.TypeInputChannel; + msgId: int; + }; } export namespace bots { @@ -13753,13 +13826,13 @@ namespace Api { | auth.SendCode | auth.SignUp | auth.SignIn | auth.LogOut | auth.ResetAuthorizations | auth.ExportAuthorization | auth.ImportAuthorization | auth.BindTempAuthKey | auth.ImportBotAuthorization | auth.CheckPassword | auth.RequestPasswordRecovery | auth.RecoverPassword | auth.ResendCode | auth.CancelCode | auth.DropTempAuthKeys | auth.ExportLoginToken | auth.ImportLoginToken | auth.AcceptLoginToken | auth.CheckRecoveryPassword | auth.ImportWebTokenAuthorization | account.RegisterDevice | account.UnregisterDevice | account.UpdateNotifySettings | account.GetNotifySettings | account.ResetNotifySettings | account.UpdateProfile | account.UpdateStatus | account.GetWallPapers | account.ReportPeer | account.CheckUsername | account.UpdateUsername | account.GetPrivacy | account.SetPrivacy | account.DeleteAccount | account.GetAccountTTL | account.SetAccountTTL | account.SendChangePhoneCode | account.ChangePhone | account.UpdateDeviceLocked | account.GetAuthorizations | account.ResetAuthorization | account.GetPassword | account.GetPasswordSettings | account.UpdatePasswordSettings | account.SendConfirmPhoneCode | account.ConfirmPhone | account.GetTmpPassword | account.GetWebAuthorizations | account.ResetWebAuthorization | account.ResetWebAuthorizations | account.GetAllSecureValues | account.GetSecureValue | account.SaveSecureValue | account.DeleteSecureValue | account.GetAuthorizationForm | account.AcceptAuthorization | account.SendVerifyPhoneCode | account.VerifyPhone | account.SendVerifyEmailCode | account.VerifyEmail | account.InitTakeoutSession | account.FinishTakeoutSession | account.ConfirmPasswordEmail | account.ResendPasswordEmail | account.CancelPasswordEmail | account.GetContactSignUpNotification | account.SetContactSignUpNotification | account.GetNotifyExceptions | account.GetWallPaper | account.UploadWallPaper | account.SaveWallPaper | account.InstallWallPaper | account.ResetWallPapers | account.GetAutoDownloadSettings | account.SaveAutoDownloadSettings | account.UploadTheme | account.CreateTheme | account.UpdateTheme | account.SaveTheme | account.InstallTheme | account.GetTheme | account.GetThemes | account.SetContentSettings | account.GetContentSettings | account.GetMultiWallPapers | account.GetGlobalPrivacySettings | account.SetGlobalPrivacySettings | account.ReportProfilePhoto | account.ResetPassword | account.DeclinePasswordReset | account.GetChatThemes | account.SetAuthorizationTTL | account.ChangeAuthorizationSettings | account.GetSavedRingtones | account.SaveRingtone | account.UploadRingtone | account.UpdateEmojiStatus | account.GetDefaultEmojiStatuses | account.GetRecentEmojiStatuses | account.ClearRecentEmojiStatuses | account.ReorderUsernames | account.ToggleUsername | users.GetUsers | users.GetFullUser | users.SetSecureValueErrors - | contacts.GetContactIDs | contacts.GetStatuses | contacts.GetContacts | contacts.ImportContacts | contacts.DeleteContacts | contacts.DeleteByPhones | contacts.Block | contacts.Unblock | contacts.GetBlocked | contacts.Search | contacts.ResolveUsername | contacts.GetTopPeers | contacts.ResetTopPeerRating | contacts.ResetSaved | contacts.GetSaved | contacts.ToggleTopPeers | contacts.AddContact | contacts.AcceptContact | contacts.GetLocated | contacts.BlockFromReplies | contacts.ResolvePhone - | messages.GetMessages | messages.GetDialogs | messages.GetHistory | messages.Search | messages.ReadHistory | messages.DeleteHistory | messages.DeleteMessages | messages.ReceivedMessages | messages.SetTyping | messages.SendMessage | messages.SendMedia | messages.ForwardMessages | messages.ReportSpam | messages.GetPeerSettings | messages.Report | messages.GetChats | messages.GetFullChat | messages.EditChatTitle | messages.EditChatPhoto | messages.AddChatUser | messages.DeleteChatUser | messages.CreateChat | messages.GetDhConfig | messages.RequestEncryption | messages.AcceptEncryption | messages.DiscardEncryption | messages.SetEncryptedTyping | messages.ReadEncryptedHistory | messages.SendEncrypted | messages.SendEncryptedFile | messages.SendEncryptedService | messages.ReceivedQueue | messages.ReportEncryptedSpam | messages.ReadMessageContents | messages.GetStickers | messages.GetAllStickers | messages.GetWebPagePreview | messages.ExportChatInvite | messages.CheckChatInvite | messages.ImportChatInvite | messages.GetStickerSet | messages.InstallStickerSet | messages.UninstallStickerSet | messages.StartBot | messages.GetMessagesViews | messages.EditChatAdmin | messages.MigrateChat | messages.SearchGlobal | messages.ReorderStickerSets | messages.GetDocumentByHash | messages.GetSavedGifs | messages.SaveGif | messages.GetInlineBotResults | messages.SetInlineBotResults | messages.SendInlineBotResult | messages.GetMessageEditData | messages.EditMessage | messages.EditInlineBotMessage | messages.GetBotCallbackAnswer | messages.SetBotCallbackAnswer | messages.GetPeerDialogs | messages.SaveDraft | messages.GetAllDrafts | messages.GetFeaturedStickers | messages.ReadFeaturedStickers | messages.GetRecentStickers | messages.SaveRecentSticker | messages.ClearRecentStickers | messages.GetArchivedStickers | messages.GetMaskStickers | messages.GetAttachedStickers | messages.SetGameScore | messages.SetInlineGameScore | messages.GetGameHighScores | messages.GetInlineGameHighScores | messages.GetCommonChats | messages.GetAllChats | messages.GetWebPage | messages.ToggleDialogPin | messages.ReorderPinnedDialogs | messages.GetPinnedDialogs | messages.SetBotShippingResults | messages.SetBotPrecheckoutResults | messages.UploadMedia | messages.SendScreenshotNotification | messages.GetFavedStickers | messages.FaveSticker | messages.GetUnreadMentions | messages.ReadMentions | messages.GetRecentLocations | messages.SendMultiMedia | messages.UploadEncryptedFile | messages.SearchStickerSets | messages.GetSplitRanges | messages.MarkDialogUnread | messages.GetDialogUnreadMarks | messages.ClearAllDrafts | messages.UpdatePinnedMessage | messages.SendVote | messages.GetPollResults | messages.GetOnlines | messages.EditChatAbout | messages.EditChatDefaultBannedRights | messages.GetEmojiKeywords | messages.GetEmojiKeywordsDifference | messages.GetEmojiKeywordsLanguages | messages.GetEmojiURL | messages.GetSearchCounters | messages.RequestUrlAuth | messages.AcceptUrlAuth | messages.HidePeerSettingsBar | messages.GetScheduledHistory | messages.GetScheduledMessages | messages.SendScheduledMessages | messages.DeleteScheduledMessages | messages.GetPollVotes | messages.ToggleStickerSets | messages.GetDialogFilters | messages.GetSuggestedDialogFilters | messages.UpdateDialogFilter | messages.UpdateDialogFiltersOrder | messages.GetOldFeaturedStickers | messages.GetReplies | messages.GetDiscussionMessage | messages.ReadDiscussion | messages.UnpinAllMessages | messages.DeleteChat | messages.DeletePhoneCallHistory | messages.CheckHistoryImport | messages.InitHistoryImport | messages.UploadImportedMedia | messages.StartHistoryImport | messages.GetExportedChatInvites | messages.GetExportedChatInvite | messages.EditExportedChatInvite | messages.DeleteRevokedExportedChatInvites | messages.DeleteExportedChatInvite | messages.GetAdminsWithInvites | messages.GetChatInviteImporters | messages.SetHistoryTTL | messages.CheckHistoryImportPeer | messages.SetChatTheme | messages.GetMessageReadParticipants | messages.GetSearchResultsCalendar | messages.GetSearchResultsPositions | messages.HideChatJoinRequest | messages.HideAllChatJoinRequests | messages.ToggleNoForwards | messages.SaveDefaultSendAs | messages.SendReaction | messages.GetMessagesReactions | messages.GetMessageReactionsList | messages.SetChatAvailableReactions | messages.GetAvailableReactions | messages.SetDefaultReaction | messages.TranslateText | messages.GetUnreadReactions | messages.ReadReactions | messages.SearchSentMedia | messages.GetAttachMenuBots | messages.GetAttachMenuBot | messages.ToggleBotInAttachMenu | messages.RequestWebView | messages.ProlongWebView | messages.RequestSimpleWebView | messages.SendWebViewResultMessage | messages.SendWebViewData | messages.TranscribeAudio | messages.RateTranscribedAudio | messages.GetCustomEmojiDocuments | messages.GetEmojiStickers | messages.GetFeaturedEmojiStickers | messages.ReportReaction | messages.GetTopReactions | messages.GetRecentReactions | messages.ClearRecentReactions | messages.GetExtendedMedia + | contacts.GetContactIDs | contacts.GetStatuses | contacts.GetContacts | contacts.ImportContacts | contacts.DeleteContacts | contacts.DeleteByPhones | contacts.Block | contacts.Unblock | contacts.GetBlocked | contacts.Search | contacts.ResolveUsername | contacts.GetTopPeers | contacts.ResetTopPeerRating | contacts.ResetSaved | contacts.GetSaved | contacts.ToggleTopPeers | contacts.AddContact | contacts.AcceptContact | contacts.GetLocated | contacts.BlockFromReplies | contacts.ResolvePhone | contacts.ExportContactToken | contacts.ImportContactToken + | messages.GetMessages | messages.GetDialogs | messages.GetHistory | messages.Search | messages.ReadHistory | messages.DeleteHistory | messages.DeleteMessages | messages.ReceivedMessages | messages.SetTyping | messages.SendMessage | messages.SendMedia | messages.ForwardMessages | messages.ReportSpam | messages.GetPeerSettings | messages.Report | messages.GetChats | messages.GetFullChat | messages.EditChatTitle | messages.EditChatPhoto | messages.AddChatUser | messages.DeleteChatUser | messages.CreateChat | messages.GetDhConfig | messages.RequestEncryption | messages.AcceptEncryption | messages.DiscardEncryption | messages.SetEncryptedTyping | messages.ReadEncryptedHistory | messages.SendEncrypted | messages.SendEncryptedFile | messages.SendEncryptedService | messages.ReceivedQueue | messages.ReportEncryptedSpam | messages.ReadMessageContents | messages.GetStickers | messages.GetAllStickers | messages.GetWebPagePreview | messages.ExportChatInvite | messages.CheckChatInvite | messages.ImportChatInvite | messages.GetStickerSet | messages.InstallStickerSet | messages.UninstallStickerSet | messages.StartBot | messages.GetMessagesViews | messages.EditChatAdmin | messages.MigrateChat | messages.SearchGlobal | messages.ReorderStickerSets | messages.GetDocumentByHash | messages.GetSavedGifs | messages.SaveGif | messages.GetInlineBotResults | messages.SetInlineBotResults | messages.SendInlineBotResult | messages.GetMessageEditData | messages.EditMessage | messages.EditInlineBotMessage | messages.GetBotCallbackAnswer | messages.SetBotCallbackAnswer | messages.GetPeerDialogs | messages.SaveDraft | messages.GetAllDrafts | messages.GetFeaturedStickers | messages.ReadFeaturedStickers | messages.GetRecentStickers | messages.SaveRecentSticker | messages.ClearRecentStickers | messages.GetArchivedStickers | messages.GetMaskStickers | messages.GetAttachedStickers | messages.SetGameScore | messages.SetInlineGameScore | messages.GetGameHighScores | messages.GetInlineGameHighScores | messages.GetCommonChats | messages.GetAllChats | messages.GetWebPage | messages.ToggleDialogPin | messages.ReorderPinnedDialogs | messages.GetPinnedDialogs | messages.SetBotShippingResults | messages.SetBotPrecheckoutResults | messages.UploadMedia | messages.SendScreenshotNotification | messages.GetFavedStickers | messages.FaveSticker | messages.GetUnreadMentions | messages.ReadMentions | messages.GetRecentLocations | messages.SendMultiMedia | messages.UploadEncryptedFile | messages.SearchStickerSets | messages.GetSplitRanges | messages.MarkDialogUnread | messages.GetDialogUnreadMarks | messages.ClearAllDrafts | messages.UpdatePinnedMessage | messages.SendVote | messages.GetPollResults | messages.GetOnlines | messages.EditChatAbout | messages.EditChatDefaultBannedRights | messages.GetEmojiKeywords | messages.GetEmojiKeywordsDifference | messages.GetEmojiKeywordsLanguages | messages.GetEmojiURL | messages.GetSearchCounters | messages.RequestUrlAuth | messages.AcceptUrlAuth | messages.HidePeerSettingsBar | messages.GetScheduledHistory | messages.GetScheduledMessages | messages.SendScheduledMessages | messages.DeleteScheduledMessages | messages.GetPollVotes | messages.ToggleStickerSets | messages.GetDialogFilters | messages.GetSuggestedDialogFilters | messages.UpdateDialogFilter | messages.UpdateDialogFiltersOrder | messages.GetOldFeaturedStickers | messages.GetReplies | messages.GetDiscussionMessage | messages.ReadDiscussion | messages.UnpinAllMessages | messages.DeleteChat | messages.DeletePhoneCallHistory | messages.CheckHistoryImport | messages.InitHistoryImport | messages.UploadImportedMedia | messages.StartHistoryImport | messages.GetExportedChatInvites | messages.GetExportedChatInvite | messages.EditExportedChatInvite | messages.DeleteRevokedExportedChatInvites | messages.DeleteExportedChatInvite | messages.GetAdminsWithInvites | messages.GetChatInviteImporters | messages.SetHistoryTTL | messages.CheckHistoryImportPeer | messages.SetChatTheme | messages.GetMessageReadParticipants | messages.GetSearchResultsCalendar | messages.GetSearchResultsPositions | messages.HideChatJoinRequest | messages.HideAllChatJoinRequests | messages.ToggleNoForwards | messages.SaveDefaultSendAs | messages.SendReaction | messages.GetMessagesReactions | messages.GetMessageReactionsList | messages.SetChatAvailableReactions | messages.GetAvailableReactions | messages.SetDefaultReaction | messages.TranslateText | messages.GetUnreadReactions | messages.ReadReactions | messages.SearchSentMedia | messages.GetAttachMenuBots | messages.GetAttachMenuBot | messages.ToggleBotInAttachMenu | messages.RequestWebView | messages.ProlongWebView | messages.RequestSimpleWebView | messages.SendWebViewResultMessage | messages.SendWebViewData | messages.TranscribeAudio | messages.RateTranscribedAudio | messages.GetCustomEmojiDocuments | messages.GetEmojiStickers | messages.GetFeaturedEmojiStickers | messages.ReportReaction | messages.GetTopReactions | messages.GetRecentReactions | messages.ClearRecentReactions | messages.GetExtendedMedia | messages.SetDefaultHistoryTTL | messages.GetDefaultHistoryTTL | updates.GetState | updates.GetDifference | updates.GetChannelDifference | photos.UpdateProfilePhoto | photos.UploadProfilePhoto | photos.DeletePhotos | photos.GetUserPhotos | upload.SaveFilePart | upload.GetFile | upload.SaveBigFilePart | upload.GetWebFile | upload.GetCdnFile | upload.ReuploadCdnFile | upload.GetCdnFileHashes | upload.GetFileHashes | help.GetConfig | help.GetNearestDc | help.GetAppUpdate | help.GetInviteText | help.GetSupport | help.GetAppChangelog | help.SetBotUpdatesStatus | help.GetCdnConfig | help.GetRecentMeUrls | help.GetTermsOfServiceUpdate | help.AcceptTermsOfService | help.GetDeepLinkInfo | help.GetAppConfig | help.SaveAppLog | help.GetPassportConfig | help.GetSupportName | help.GetUserInfo | help.EditUserInfo | help.GetPromoData | help.HidePromoData | help.DismissSuggestion | help.GetCountriesList | help.GetPremiumPromo - | channels.ReadHistory | channels.DeleteMessages | channels.ReportSpam | channels.GetMessages | channels.GetParticipants | channels.GetParticipant | channels.GetChannels | channels.GetFullChannel | channels.CreateChannel | channels.EditAdmin | channels.EditTitle | channels.EditPhoto | channels.CheckUsername | channels.UpdateUsername | channels.JoinChannel | channels.LeaveChannel | channels.InviteToChannel | channels.DeleteChannel | channels.ExportMessageLink | channels.ToggleSignatures | channels.GetAdminedPublicChannels | channels.EditBanned | channels.GetAdminLog | channels.SetStickers | channels.ReadMessageContents | channels.DeleteHistory | channels.TogglePreHistoryHidden | channels.GetLeftChannels | channels.GetGroupsForDiscussion | channels.SetDiscussionGroup | channels.EditCreator | channels.EditLocation | channels.ToggleSlowMode | channels.GetInactiveChannels | channels.ConvertToGigagroup | channels.ViewSponsoredMessage | channels.GetSponsoredMessages | channels.GetSendAs | channels.DeleteParticipantHistory | channels.ToggleJoinToSend | channels.ToggleJoinRequest | channels.ReorderUsernames | channels.ToggleUsername | channels.DeactivateAllUsernames | channels.ToggleForum | channels.CreateForumTopic | channels.GetForumTopics | channels.GetForumTopicsByID | channels.EditForumTopic | channels.UpdatePinnedForumTopic | channels.DeleteTopicHistory | channels.ReorderPinnedForumTopics + | channels.ReadHistory | channels.DeleteMessages | channels.ReportSpam | channels.GetMessages | channels.GetParticipants | channels.GetParticipant | channels.GetChannels | channels.GetFullChannel | channels.CreateChannel | channels.EditAdmin | channels.EditTitle | channels.EditPhoto | channels.CheckUsername | channels.UpdateUsername | channels.JoinChannel | channels.LeaveChannel | channels.InviteToChannel | channels.DeleteChannel | channels.ExportMessageLink | channels.ToggleSignatures | channels.GetAdminedPublicChannels | channels.EditBanned | channels.GetAdminLog | channels.SetStickers | channels.ReadMessageContents | channels.DeleteHistory | channels.TogglePreHistoryHidden | channels.GetLeftChannels | channels.GetGroupsForDiscussion | channels.SetDiscussionGroup | channels.EditCreator | channels.EditLocation | channels.ToggleSlowMode | channels.GetInactiveChannels | channels.ConvertToGigagroup | channels.ViewSponsoredMessage | channels.GetSponsoredMessages | channels.GetSendAs | channels.DeleteParticipantHistory | channels.ToggleJoinToSend | channels.ToggleJoinRequest | channels.ReorderUsernames | channels.ToggleUsername | channels.DeactivateAllUsernames | channels.ToggleForum | channels.CreateForumTopic | channels.GetForumTopics | channels.GetForumTopicsByID | channels.EditForumTopic | channels.UpdatePinnedForumTopic | channels.DeleteTopicHistory | channels.ReorderPinnedForumTopics | channels.ToggleAntiSpam | channels.ReportAntiSpamFalsePositive | bots.SendCustomRequest | bots.AnswerWebhookJSONQuery | bots.SetBotCommands | bots.ResetBotCommands | bots.GetBotCommands | bots.SetBotMenuButton | bots.GetBotMenuButton | bots.SetBotBroadcastDefaultAdminRights | bots.SetBotGroupDefaultAdminRights | payments.GetPaymentForm | payments.GetPaymentReceipt | payments.ValidateRequestedInfo | payments.SendPaymentForm | payments.GetSavedInfo | payments.ClearSavedInfo | payments.GetBankCardData | payments.ExportInvoice | payments.AssignAppStoreTransaction | payments.AssignPlayMarketTransaction | payments.CanPurchasePremium | stickers.CreateStickerSet | stickers.RemoveStickerFromSet | stickers.ChangeStickerPosition | stickers.AddStickerToSet | stickers.SetStickerSetThumb | stickers.CheckShortName | stickers.SuggestShortName diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index d6d989f25..537e341f6 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -79,7 +79,7 @@ chatForbidden#6592a1a7 id:long title:string = Chat; channel#83259464 flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector = Chat; channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true id:long access_hash:long title:string until_date:flags.16?int = Chat; chatFull#c9d31138 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions = ChatFull; -channelFull#f2355507 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions = ChatFull; +channelFull#f2355507 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions = ChatFull; chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; chatParticipantAdmin#a0933f5b user_id:long inviter_id:long date:int = ChatParticipant; @@ -129,7 +129,7 @@ messageActionContactSignUp#f3f25f76 = MessageAction; messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; -messageActionSetMessagesTTL#aa1afbfd period:int = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; messageActionSetChatTheme#aa786345 emoticon:string = MessageAction; messageActionChatJoinedByRequest#ebbca3cb = MessageAction; @@ -137,8 +137,8 @@ messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; messageActionGiftPremium#aba0f5c6 currency:string amount:long months:int = MessageAction; messageActionTopicCreate#d999256 flags:# title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; -messageActionTopicEdit#b18a431c flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool = MessageAction; -dialog#a8edd0f5 flags:# pinned:flags.2?true unread_mark:flags.3?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int = Dialog; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +dialog#d58a08c6 flags:# pinned:flags.2?true unread_mark:flags.3?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; photoEmpty#2331b22d id:long = Photo; photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; @@ -568,6 +568,7 @@ auth.codeTypeSms#72a3158c = auth.CodeType; auth.codeTypeCall#741cd3e3 = auth.CodeType; auth.codeTypeFlashCall#226ccefb = auth.CodeType; auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; @@ -575,6 +576,7 @@ auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; auth.sentCodeTypeEmailCode#5a159841 flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int next_phone_login_date:flags.2?int = auth.SentCodeType; auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; @@ -752,6 +754,7 @@ channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLo channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true = ChannelAdminLogEventsFilter; @@ -1062,8 +1065,10 @@ messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; forumTopicDeleted#23f109b id:int = ForumTopic; -forumTopic#71701da9 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true id:int date:int title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; +forumTopic#71701da9 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true id:int date:int title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; ---functions--- initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; @@ -1155,7 +1160,7 @@ messages.editChatTitle#73783ffd chat_id:long title:string = Updates; messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; messages.addChatUser#f24753e3 chat_id:long user_id:InputUser fwd_limit:int = Updates; messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; -messages.createChat#9cb126e users:Vector title:string = Updates; +messages.createChat#34a818 flags:# users:Vector title:string ttl_period:flags.0?int = Updates; messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; @@ -1274,7 +1279,7 @@ channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipant channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; channels.getChannels#a7f6bbb id:Vector = messages.Chats; channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; -channels.createChannel#3d5fb10f flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string = Updates; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; channels.editAdmin#d33c8902 channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:string = Updates; channels.editTitle#566decd0 channel:InputChannel title:string = Updates; channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; @@ -1297,6 +1302,13 @@ channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.toggleForum#a4298b29 channel:InputChannel enabled:Bool = Updates; +channels.createForumTopic#f40c0224 flags:# channel:InputChannel title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +channels.getForumTopics#de560d1 flags:# channel:InputChannel q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +channels.getForumTopicsByID#b0831eb9 channel:InputChannel topics:Vector = messages.ForumTopics; +channels.editForumTopic#f4dfa185 flags:# channel:InputChannel topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +channels.updatePinnedForumTopic#6c2d9026 channel:InputChannel topic_id:int pinned:Bool = Updates; +channels.deleteTopicHistory#34435f2d channel:InputChannel top_msg_id:int = messages.AffectedHistory; payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 2e2058094..d69faefb0 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -263,5 +263,12 @@ "messages.getUnreadReactions", "messages.readMentions", "messages.getUnreadMentions", - "help.getPremiumPromo" + "help.getPremiumPromo", + "channels.toggleForum", + "channels.createForumTopic", + "channels.getForumTopics", + "channels.getForumTopicsByID", + "channels.editForumTopic", + "channels.updatePinnedForumTopic", + "channels.deleteTopicHistory" ] diff --git a/src/lib/gramjs/tl/static/api.tl b/src/lib/gramjs/tl/static/api.tl index 3b6976c3b..7c5b27136 100644 --- a/src/lib/gramjs/tl/static/api.tl +++ b/src/lib/gramjs/tl/static/api.tl @@ -99,7 +99,7 @@ channel#83259464 flags:# creator:flags.0?true left:flags.2?true broadcast:flags. channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true id:long access_hash:long title:string until_date:flags.16?int = Chat; chatFull#c9d31138 flags:# can_set_username:flags.7?true has_scheduled:flags.8?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions = ChatFull; -channelFull#f2355507 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions = ChatFull; +channelFull#f2355507 flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions = ChatFull; chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; @@ -155,7 +155,7 @@ messageActionContactSignUp#f3f25f76 = MessageAction; messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; -messageActionSetMessagesTTL#aa1afbfd period:int = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; messageActionSetChatTheme#aa786345 emoticon:string = MessageAction; messageActionChatJoinedByRequest#ebbca3cb = MessageAction; @@ -163,9 +163,9 @@ messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; messageActionGiftPremium#aba0f5c6 currency:string amount:long months:int = MessageAction; messageActionTopicCreate#d999256 flags:# title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; -messageActionTopicEdit#b18a431c flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; -dialog#a8edd0f5 flags:# pinned:flags.2?true unread_mark:flags.3?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int = Dialog; +dialog#d58a08c6 flags:# pinned:flags.2?true unread_mark:flags.3?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; photoEmpty#2331b22d id:long = Photo; @@ -694,6 +694,7 @@ auth.codeTypeSms#72a3158c = auth.CodeType; auth.codeTypeCall#741cd3e3 = auth.CodeType; auth.codeTypeFlashCall#226ccefb = auth.CodeType; auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; @@ -702,6 +703,7 @@ auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; auth.sentCodeTypeEmailCode#5a159841 flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int next_phone_login_date:flags.2?int = auth.SentCodeType; auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; @@ -935,6 +937,7 @@ channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLo channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; @@ -1437,10 +1440,14 @@ stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; forumTopicDeleted#23f109b id:int = ForumTopic; -forumTopic#71701da9 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true id:int date:int title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; +forumTopic#71701da9 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true id:int date:int title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + ---functions--- invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; @@ -1580,6 +1587,8 @@ contacts.acceptContact#f831a20f id:InputUser = Updates; contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; messages.getMessages#63c66506 id:Vector = messages.Messages; messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; @@ -1602,7 +1611,7 @@ messages.editChatTitle#73783ffd chat_id:long title:string = Updates; messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; messages.addChatUser#f24753e3 chat_id:long user_id:InputUser fwd_limit:int = Updates; messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; -messages.createChat#9cb126e users:Vector title:string = Updates; +messages.createChat#34a818 flags:# users:Vector title:string ttl_period:flags.0?int = Updates; messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; @@ -1758,6 +1767,8 @@ messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; messages.clearRecentReactions#9dfeefb4 = Bool; messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; @@ -1809,7 +1820,7 @@ channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipant channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; channels.getChannels#a7f6bbb id:Vector = messages.Chats; channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; -channels.createChannel#3d5fb10f flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string = Updates; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; channels.editAdmin#d33c8902 channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:string = Updates; channels.editTitle#566decd0 channel:InputChannel title:string = Updates; channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; @@ -1849,10 +1860,12 @@ channels.toggleForum#a4298b29 channel:InputChannel enabled:Bool = Updates; channels.createForumTopic#f40c0224 flags:# channel:InputChannel title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; channels.getForumTopics#de560d1 flags:# channel:InputChannel q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; channels.getForumTopicsByID#b0831eb9 channel:InputChannel topics:Vector = messages.ForumTopics; -channels.editForumTopic#6c883e2d flags:# channel:InputChannel topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool = Updates; +channels.editForumTopic#f4dfa185 flags:# channel:InputChannel topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; channels.updatePinnedForumTopic#6c2d9026 channel:InputChannel topic_id:int pinned:Bool = Updates; channels.deleteTopicHistory#34435f2d channel:InputChannel top_msg_id:int = messages.AffectedHistory; channels.reorderPinnedForumTopics#2950a18f flags:# force:flags.0?true channel:InputChannel order:Vector = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; @@ -1931,4 +1944,4 @@ stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel stats.getMessagePublicForwards#5630281b channel:InputChannel msg_id:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; -// LAYER 149 +// LAYER 150 diff --git a/src/lib/teact/teact-dom.ts b/src/lib/teact/teact-dom.ts index 7fc3d47a4..90b5e6187 100644 --- a/src/lib/teact/teact-dom.ts +++ b/src/lib/teact/teact-dom.ts @@ -709,6 +709,8 @@ function DEBUG_checkKeyUniqueness(children: VirtualElementChildren) { }, []); if (keys.length !== unique(keys).length) { + // eslint-disable-next-line no-console + console.warn('[Teact] Duplicated keys:', keys.filter((e, i, a) => a.indexOf(e) !== i)); throw new Error('[Teact] Children keys are not unique'); } } diff --git a/src/styles/Telegram T.json b/src/styles/Telegram T.json index f536b76f1..3bbcf8378 100644 --- a/src/styles/Telegram T.json +++ b/src/styles/Telegram T.json @@ -2,7 +2,7 @@ "metadata": { "name": "Telegram T", "lastOpened": 0, - "created": 1670242935050 + "created": 1670592461603 }, "iconSets": [ { @@ -157,13 +157,45 @@ }, { "selection": [ + { + "order": 746, + "id": 84, + "name": "forums", + "prevSize": 32, + "code": 59828, + "tempChar": "" + }, + { + "order": 743, + "id": 83, + "name": "hashtag", + "prevSize": 32, + "code": 59825, + "tempChar": "" + }, + { + "order": 744, + "id": 82, + "name": "reopen-topic", + "prevSize": 32, + "code": 59826, + "tempChar": "" + }, + { + "order": 745, + "id": 81, + "name": "close-topic", + "prevSize": 32, + "code": 59827, + "tempChar": "" + }, { "order": 739, "id": 79, "name": "open-in-new-tab", "prevSize": 32, "code": 59823, - "tempChar": "" + "tempChar": "" }, { "order": 738, @@ -171,7 +203,7 @@ "name": "pip", "prevSize": 32, "code": 59822, - "tempChar": "" + "tempChar": "" }, { "order": 737, @@ -179,7 +211,7 @@ "name": "gift", "prevSize": 32, "code": 59821, - "tempChar": "" + "tempChar": "" }, { "order": 734, @@ -187,7 +219,7 @@ "name": "sort", "prevSize": 32, "code": 59820, - "tempChar": "" + "tempChar": "" }, { "order": 732, @@ -195,7 +227,7 @@ "name": "web", "prevSize": 32, "code": 59819, - "tempChar": "" + "tempChar": "" }, { "order": 731, @@ -203,7 +235,7 @@ "name": "transcribe", "prevSize": 32, "code": 59818, - "tempChar": "" + "tempChar": "" }, { "order": 719, @@ -211,7 +243,7 @@ "name": "add-one-badge", "prevSize": 32, "code": 59803, - "tempChar": "" + "tempChar": "" }, { "order": 720, @@ -219,7 +251,7 @@ "name": "chat-badge", "prevSize": 32, "code": 59808, - "tempChar": "" + "tempChar": "" }, { "order": 721, @@ -227,7 +259,7 @@ "name": "chats-badge", "prevSize": 32, "code": 59809, - "tempChar": "" + "tempChar": "" }, { "order": 722, @@ -235,7 +267,7 @@ "name": "double-badge", "prevSize": 32, "code": 59810, - "tempChar": "" + "tempChar": "" }, { "order": 723, @@ -243,7 +275,7 @@ "name": "file-badge", "prevSize": 32, "code": 59811, - "tempChar": "" + "tempChar": "" }, { "order": 724, @@ -251,7 +283,7 @@ "name": "folder-badge", "prevSize": 32, "code": 59812, - "tempChar": "" + "tempChar": "" }, { "order": 726, @@ -259,7 +291,7 @@ "name": "link-badge", "prevSize": 32, "code": 59813, - "tempChar": "" + "tempChar": "" }, { "order": 725, @@ -267,7 +299,7 @@ "name": "pin-badge", "prevSize": 32, "code": 59814, - "tempChar": "" + "tempChar": "" }, { "order": 727, @@ -275,7 +307,7 @@ "name": "premium", "prevSize": 32, "code": 59815, - "tempChar": "" + "tempChar": "" }, { "order": 728, @@ -283,7 +315,7 @@ "name": "unlock-badge", "prevSize": 32, "code": 59816, - "tempChar": "" + "tempChar": "" }, { "order": 729, @@ -291,7 +323,7 @@ "name": "lock-badge", "prevSize": 32, "code": 59817, - "tempChar": "" + "tempChar": "" }, { "order": 715, @@ -299,7 +331,7 @@ "name": "key", "prevSize": 32, "code": 59802, - "tempChar": "" + "tempChar": "" }, { "order": 714, @@ -307,7 +339,7 @@ "name": "heart-outline", "prevSize": 32, "code": 59806, - "tempChar": "" + "tempChar": "" }, { "order": 713, @@ -315,7 +347,7 @@ "name": "heart", "prevSize": 32, "code": 59807, - "tempChar": "" + "tempChar": "" }, { "order": 712, @@ -323,7 +355,7 @@ "name": "word-wrap", "prevSize": 32, "code": 59805, - "tempChar": "" + "tempChar": "" }, { "order": 708, @@ -331,7 +363,7 @@ "name": "webapp", "prevSize": 32, "code": 59795, - "tempChar": "" + "tempChar": "" }, { "order": 707, @@ -339,7 +371,7 @@ "name": "reload", "prevSize": 32, "code": 59796, - "tempChar": "" + "tempChar": "" }, { "order": 706, @@ -347,7 +379,7 @@ "name": "install", "prevSize": 32, "code": 59801, - "tempChar": "" + "tempChar": "" }, { "order": 705, @@ -355,7 +387,7 @@ "name": "favorite-filled", "prevSize": 32, "code": 59800, - "tempChar": "" + "tempChar": "" }, { "order": 702, @@ -363,7 +395,7 @@ "name": "share-screen", "prevSize": 32, "code": 59770, - "tempChar": "" + "tempChar": "" }, { "order": 701, @@ -371,7 +403,7 @@ "name": "video-outlined", "prevSize": 32, "code": 59799, - "tempChar": "" + "tempChar": "" }, { "order": 700, @@ -379,7 +411,7 @@ "name": "stats", "prevSize": 32, "code": 59798, - "tempChar": "" + "tempChar": "" }, { "order": 699, @@ -387,7 +419,7 @@ "name": "copy-media", "prevSize": 32, "code": 59797, - "tempChar": "" + "tempChar": "" }, { "order": 704, @@ -395,7 +427,7 @@ "name": "sidebar", "prevSize": 32, "code": 59794, - "tempChar": "" + "tempChar": "" }, { "order": 690, @@ -403,7 +435,7 @@ "name": "video-stop", "prevSize": 32, "code": 59787, - "tempChar": "" + "tempChar": "" }, { "order": 678, @@ -411,7 +443,7 @@ "name": "speaker", "prevSize": 32, "code": 59777, - "tempChar": "" + "tempChar": "" }, { "order": 679, @@ -419,7 +451,7 @@ "name": "speaker-outline", "prevSize": 32, "code": 59778, - "tempChar": "" + "tempChar": "" }, { "order": 680, @@ -427,7 +459,7 @@ "name": "phone-discard-outline", "prevSize": 32, "code": 59779, - "tempChar": "" + "tempChar": "" }, { "order": 681, @@ -435,7 +467,7 @@ "name": "allow-speak", "prevSize": 32, "code": 59780, - "tempChar": "" + "tempChar": "" }, { "order": 682, @@ -443,7 +475,7 @@ "name": "stop-raising-hand", "prevSize": 32, "code": 59781, - "tempChar": "" + "tempChar": "" }, { "order": 683, @@ -451,7 +483,7 @@ "name": "share-screen-outlined", "prevSize": 32, "code": 59782, - "tempChar": "" + "tempChar": "" }, { "order": 684, @@ -459,7 +491,7 @@ "name": "voice-chat", "prevSize": 32, "code": 59783, - "tempChar": "" + "tempChar": "" }, { "order": 689, @@ -467,7 +499,7 @@ "name": "video", "prevSize": 32, "code": 59784, - "tempChar": "" + "tempChar": "" }, { "order": 686, @@ -475,7 +507,7 @@ "name": "noise-suppression", "prevSize": 32, "code": 59785, - "tempChar": "" + "tempChar": "" }, { "order": 703, @@ -483,7 +515,7 @@ "name": "phone-discard", "prevSize": 32, "code": 59786, - "tempChar": "" + "tempChar": "" }, { "order": 667, @@ -491,7 +523,7 @@ "name": "bot-commands-filled", "prevSize": 32, "code": 59775, - "tempChar": "" + "tempChar": "" }, { "order": 664, @@ -499,7 +531,7 @@ "name": "reply-filled", "prevSize": 32, "code": 59776, - "tempChar": "" + "tempChar": "" }, { "order": 656, @@ -507,7 +539,7 @@ "name": "bug", "prevSize": 32, "code": 59774, - "tempChar": "" + "tempChar": "" }, { "order": 619, @@ -515,7 +547,7 @@ "name": "data", "prevSize": 32, "code": 59773, - "tempChar": "" + "tempChar": "" }, { "order": 622, @@ -523,7 +555,7 @@ "name": "darkmode", "prevSize": 32, "code": 59769, - "tempChar": "" + "tempChar": "" }, { "order": 711, @@ -531,7 +563,7 @@ "name": "animations", "prevSize": 32, "code": 59804, - "tempChar": "" + "tempChar": "" }, { "order": 626, @@ -539,7 +571,7 @@ "name": "enter", "prevSize": 32, "code": 59771, - "tempChar": "" + "tempChar": "" }, { "order": 627, @@ -547,7 +579,7 @@ "name": "fontsize", "prevSize": 32, "code": 59772, - "tempChar": "" + "tempChar": "" }, { "order": 630, @@ -555,7 +587,7 @@ "name": "permissions", "prevSize": 32, "code": 59766, - "tempChar": "" + "tempChar": "" }, { "order": 631, @@ -563,7 +595,7 @@ "name": "card", "prevSize": 32, "code": 59767, - "tempChar": "" + "tempChar": "" }, { "order": 634, @@ -571,7 +603,7 @@ "name": "truck", "prevSize": 32, "code": 59768, - "tempChar": "" + "tempChar": "" }, { "order": 663, @@ -579,7 +611,7 @@ "name": "share-filled", "prevSize": 32, "code": 59738, - "tempChar": "" + "tempChar": "" }, { "order": 638, @@ -587,7 +619,7 @@ "name": "bold", "prevSize": 32, "code": 59745, - "tempChar": "" + "tempChar": "" }, { "order": 639, @@ -595,7 +627,7 @@ "name": "bot-command", "prevSize": 32, "code": 59746, - "tempChar": "" + "tempChar": "" }, { "order": 642, @@ -603,7 +635,7 @@ "name": "calendar-filter", "prevSize": 32, "code": 59747, - "tempChar": "" + "tempChar": "" }, { "order": 643, @@ -611,7 +643,7 @@ "name": "comments", "prevSize": 32, "code": 59748, - "tempChar": "" + "tempChar": "" }, { "order": 645, @@ -619,7 +651,7 @@ "name": "comments-sticker", "prevSize": 32, "code": 59749, - "tempChar": "" + "tempChar": "" }, { "order": 646, @@ -627,7 +659,7 @@ "name": "arrow-down", "prevSize": 32, "code": 59750, - "tempChar": "" + "tempChar": "" }, { "order": 668, @@ -635,7 +667,7 @@ "name": "email", "prevSize": 32, "code": 59751, - "tempChar": "" + "tempChar": "" }, { "order": 648, @@ -643,7 +675,7 @@ "name": "italic", "prevSize": 32, "code": 59752, - "tempChar": "" + "tempChar": "" }, { "order": 620, @@ -651,7 +683,7 @@ "name": "link", "prevSize": 32, "code": 59753, - "tempChar": "" + "tempChar": "" }, { "order": 742, @@ -659,7 +691,7 @@ "name": "link-broken", "prevSize": 32, "code": 59824, - "tempChar": "" + "tempChar": "" }, { "order": 621, @@ -667,7 +699,7 @@ "name": "mention", "prevSize": 32, "code": 59754, - "tempChar": "" + "tempChar": "" }, { "order": 624, @@ -675,7 +707,7 @@ "name": "monospace", "prevSize": 32, "code": 59755, - "tempChar": "" + "tempChar": "" }, { "order": 625, @@ -683,7 +715,7 @@ "name": "next", "prevSize": 32, "code": 59756, - "tempChar": "" + "tempChar": "" }, { "order": 628, @@ -691,7 +723,7 @@ "name": "password-off", "prevSize": 32, "code": 59757, - "tempChar": "" + "tempChar": "" }, { "order": 629, @@ -699,7 +731,7 @@ "name": "pin-list", "prevSize": 32, "code": 59758, - "tempChar": "" + "tempChar": "" }, { "order": 632, @@ -707,7 +739,7 @@ "name": "previous", "prevSize": 32, "code": 59759, - "tempChar": "" + "tempChar": "" }, { "order": 633, @@ -715,7 +747,7 @@ "name": "replace", "prevSize": 32, "code": 59760, - "tempChar": "" + "tempChar": "" }, { "order": 636, @@ -723,7 +755,7 @@ "name": "schedule", "prevSize": 32, "code": 59761, - "tempChar": "" + "tempChar": "" }, { "order": 691, @@ -731,7 +763,7 @@ "name": "strikethrough", "prevSize": 32, "code": 59762, - "tempChar": "" + "tempChar": "" }, { "order": 692, @@ -739,7 +771,7 @@ "name": "underlined", "prevSize": 32, "code": 59763, - "tempChar": "" + "tempChar": "" }, { "order": 641, @@ -747,7 +779,7 @@ "name": "zoom-in", "prevSize": 32, "code": 59764, - "tempChar": "" + "tempChar": "" }, { "order": 649, @@ -755,20 +787,84 @@ "name": "zoom-out", "prevSize": 32, "code": 59765, - "tempChar": "" + "tempChar": "" } ], "id": 2, "metadata": { "name": "Untitled Set", "importSize": { - "width": 768, - "height": 768 + "width": 24, + "height": 24 } }, "height": 1024, "prevSize": 32, "icons": [ + { + "id": 84, + "paths": [ + "M170.667 256c-23.564 0-42.667 19.103-42.667 42.667s19.103 42.667 42.667 42.667h42.667c23.564 0 42.667-19.103 42.667-42.667s-19.103-42.667-42.667-42.667h-42.667zM384 256c-23.564 0-42.667 19.103-42.667 42.667s19.103 42.667 42.667 42.667h469.333c23.565 0 42.667-19.103 42.667-42.667s-19.102-42.667-42.667-42.667h-469.333zM384 469.333c-23.564 0-42.667 19.102-42.667 42.667s19.103 42.667 42.667 42.667h469.333c23.565 0 42.667-19.102 42.667-42.667s-19.102-42.667-42.667-42.667h-469.333zM341.333 725.333c0-23.565 19.103-42.667 42.667-42.667h469.333c23.565 0 42.667 19.102 42.667 42.667s-19.102 42.667-42.667 42.667h-469.333c-23.564 0-42.667-19.102-42.667-42.667zM128 512c0-23.565 19.103-42.667 42.667-42.667h42.667c23.564 0 42.667 19.102 42.667 42.667s-19.103 42.667-42.667 42.667h-42.667c-23.564 0-42.667-19.102-42.667-42.667zM170.667 682.667c-23.564 0-42.667 19.102-42.667 42.667s19.103 42.667 42.667 42.667h42.667c23.564 0 42.667-19.102 42.667-42.667s-19.103-42.667-42.667-42.667h-42.667z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "forums" + ] + }, + { + "id": 83, + "paths": [ + "M348.4 846.267c-2.8 0-5.467-0.267-8.267-0.8-22.933-4.533-38-26.933-33.467-50l26.133-122.267h-74.4c-23.467 0-42.4-19.2-42.4-42.667s19.067-42.667 42.4-42.667h90.8l28.933-148.4h-68.4c-23.467 0-42.4-19.2-42.4-42.667s19.067-42.667 42.4-42.667h84.933l29.867-141.867c3.867-20 21.333-34.533 41.467-34.533h1.867c2.267 0.133 4.4 0.4 6.667 0.8 22.933 4.533 38 26.933 33.467 50l-26.8 125.6h119.467l29.867-141.867c3.867-20.133 21.467-34.533 41.6-34.533 2.8 0 5.467 0.267 8.267 0.8 22.933 4.533 38 26.933 33.467 50l-26.8 125.6h78.667c23.467 0 42.4 19.2 42.4 42.667s-19.067 42.667-42.4 42.667h-95.067l-28.933 148.4h72.533c23.467 0 42.4 19.2 42.4 42.667s-19.067 42.667-42.4 42.667h-89.2l-29.2 138.533c-3.867 20.133-21.467 34.533-41.6 34.533-2.8 0-5.467-0.267-8.267-0.8-22.933-4.533-38-26.933-33.467-50l26.133-122.267h-119.467l-29.2 138.533c-3.867 19.467-20.133 33.733-39.733 34.533h-1.867zM435.733 587.867h119.467l28.933-148.4h-119.467l-28.933 148.4z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "hashtag" + ] + }, + { + "id": 82, + "paths": [ + "M128 512c0-212.133 171.867-384 384-384s384 171.867 384 384c0 212.133-171.867 384-384 384s-384-171.867-384-384zM512 42.667c-259.2 0-469.333 210.133-469.333 469.333s210.133 469.333 469.333 469.333c259.2 0 469.333-210.133 469.333-469.333s-210.133-469.333-469.333-469.333z", + "M423.333 322.667l264.267 176.133c7.333 4.8 9.2 14.667 4.4 22-1.2 1.733-2.667 3.2-4.4 4.4l-264.267 176.133c-7.333 4.8-17.067 2.933-22-4.4-1.733-2.533-2.667-5.6-2.667-8.8v-352.267c0-8.8 7.067-15.867 15.867-15.867 3.067 0 6.133 0.933 8.8 2.667v0z" + ], + "attrs": [ + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "reopen-topic" + ] + }, + { + "id": 81, + "paths": [ + "M128 512c0-212.133 171.867-384 384-384s384 171.867 384 384c0 212.133-171.867 384-384 384s-384-171.867-384-384zM512 42.667c-259.2 0-469.333 210.133-469.333 469.333s210.133 469.333 469.333 469.333c259.2 0 469.333-210.133 469.333-469.333s-210.133-469.333-469.333-469.333z", + "M439.467 318.533c13.333 0 24.133 10.8 24.133 24.133v338.667c0 13.333-10.8 24.133-24.133 24.133h-72.533c-13.333 0-24.133-10.8-24.133-24.133v-338.667c0-13.333 10.8-24.133 24.133-24.133h72.533zM657.067 318.533c13.333 0 24.133 10.8 24.133 24.133v338.667c0 13.333-10.8 24.133-24.133 24.133h-72.533c-13.333 0-24.133-10.8-24.133-24.133v-338.667c0-13.333 10.8-24.133 24.133-24.133h72.533z" + ], + "attrs": [ + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "close-topic" + ] + }, { "id": 79, "paths": [ @@ -3421,35 +3517,35 @@ }, { "paths": [ - "M512 265.481c104.73 0 189.63 84.9 189.63 189.63v265.481c0 16.894-20.426 25.355-32.372 13.409l-43.48-43.48-43.48 43.48c-7.405 7.405-19.412 7.405-26.818 0l-43.48-43.48-43.48 43.48c-7.405 7.405-19.412 7.405-26.818 0l-43.48-43.48-43.48 43.48c-11.946 11.946-32.372 3.485-32.372-13.409v-265.481c0-104.73 84.9-189.63 189.63-189.63zM512 303.407c-83.784 0-151.704 67.92-151.704 151.704v219.701l24.517-24.517c7.405-7.405 19.412-7.405 26.818 0l43.48 43.48 43.48-43.48c7.405-7.405 19.412-7.405 26.818 0l43.48 43.48 43.48-43.48c7.405-7.405 19.412-7.405 26.818 0l24.517 24.517v-219.701c0-83.784-67.92-151.704-151.704-151.704zM436.148 417.185c20.946 0 37.926 16.98 37.926 37.926s-16.98 37.926-37.926 37.926c-20.946 0-37.926-16.98-37.926-37.926s16.98-37.926 37.926-37.926zM587.852 417.185c20.946 0 37.926 16.98 37.926 37.926s-16.98 37.926-37.926 37.926c-20.946 0-37.926-16.98-37.926-37.926s16.98-37.926 37.926-37.926z" + "M512 118.187c-167.028 0-302.432 135.403-302.432 302.432v358.044c0 20.685 0 31.031 3.037 37.286 5.824 11.994 18.818 18.756 31.985 16.649 6.867-1.097 15.34-7.023 32.286-18.889 9.177-6.426 13.766-9.634 18.38-11.034 8.765-2.662 18.264-1.207 25.83 3.959 3.982 2.718 7.397 7.155 14.228 16.038l42.275 54.955c11.606 15.091 17.408 22.635 24.657 25.741 6.364 2.726 13.472 3.187 20.135 1.306 7.589-2.142 14.317-8.875 27.779-22.332l27.627-27.627c11.977-11.977 17.963-17.963 24.87-20.207 6.071-1.975 12.617-1.975 18.692 0 6.903 2.244 12.894 8.23 24.87 20.207l27.627 27.627c13.457 13.461 20.19 20.19 27.78 22.332 6.66 1.882 13.769 1.421 20.13-1.306 7.249-3.106 13.052-10.65 24.657-25.741l42.274-54.955c6.831-8.883 10.249-13.316 14.229-16.038 7.569-5.167 17.062-6.622 25.83-3.959 4.612 1.399 9.199 4.608 18.377 11.034 16.947 11.866 25.417 17.792 32.286 18.889 13.171 2.108 26.159-4.655 31.987-16.649 3.034-6.255 3.034-16.602 3.034-37.286v-358.044c0-167.028-135.398-302.432-302.43-302.432v0zM693.461 420.618c0 33.406-27.076 60.487-60.484 60.487-33.404 0-60.489-27.081-60.489-60.487s27.085-60.487 60.489-60.487c33.408 0 60.484 27.081 60.484 60.487zM451.516 420.618c0 33.406-27.081 60.487-60.486 60.487s-60.487-27.081-60.487-60.487c0-33.405 27.081-60.486 60.487-60.486s60.486 27.081 60.486 60.486z" ], "attrs": [ {} ], - "isMulticolor": false, - "isMulticolor2": false, "grid": 24, "tags": [ "avatar-deleted-account" ], "defaultCode": 59659, - "id": 94 + "id": 94, + "isMulticolor": false, + "isMulticolor2": false }, { "paths": [ - "M350.543 737.169c-12.639 7.022-28.172-2.118-28.172-16.577v-360.296c0-41.892 33.96-75.852 75.852-75.852h227.556c41.892 0 75.852 33.96 75.852 75.852v360.296c0 14.459-15.533 23.599-28.172 16.577l-161.457-89.699-161.457 89.699zM663.704 360.296c0-20.946-16.98-37.926-37.926-37.926h-227.556c-20.946 0-37.926 16.98-37.926 37.926v328.068l142.494-79.164c5.727-3.182 12.691-3.182 18.418 0l142.494 79.164v-328.068z" + "M118.154 447.356c0-128.532 0-192.799 22.554-242.911 25.672-57.041 71.34-102.708 128.381-128.381 50.112-22.554 114.379-22.554 242.912-22.554s192.798 0 242.91 22.554c57.041 25.672 102.711 71.34 128.384 128.381 22.554 50.112 22.554 114.379 22.554 242.911v322.389c0 97.446 0 146.172-19.627 169.856-17.050 20.574-42.748 31.974-69.44 30.805-30.733-1.344-66.854-34.044-139.098-99.443l-74.052-67.038c-32.371-29.303-48.555-43.955-66.88-49.51-16.137-4.89-33.365-4.89-49.502 0-18.325 5.555-34.509 20.207-66.879 49.51l-74.053 67.038c-72.244 65.399-108.366 98.099-139.097 99.443-26.693 1.169-52.393-10.231-69.44-30.805-19.627-23.684-19.627-72.41-19.627-169.856v-322.389z" ], "attrs": [ {} ], - "isMulticolor": false, - "isMulticolor2": false, "grid": 24, "tags": [ "avatar-saved-messages" ], "defaultCode": 59660, - "id": 95 + "id": 95, + "isMulticolor": false, + "isMulticolor2": false }, { "paths": [ @@ -3520,7 +3616,7 @@ "name": "select", "prevSize": 32, "code": 59744, - "tempChar": "" + "tempChar": "" }, { "order": 480, @@ -3528,7 +3624,7 @@ "name": "folder", "prevSize": 32, "code": 59667, - "tempChar": "" + "tempChar": "" }, { "order": 481, @@ -3536,7 +3632,7 @@ "name": "bots", "prevSize": 32, "code": 59669, - "tempChar": "" + "tempChar": "" }, { "order": 482, @@ -3544,7 +3640,7 @@ "name": "calendar", "prevSize": 32, "code": 59670, - "tempChar": "" + "tempChar": "" }, { "order": 483, @@ -3552,7 +3648,7 @@ "name": "cloud-download", "prevSize": 32, "code": 59671, - "tempChar": "" + "tempChar": "" }, { "order": 484, @@ -3560,7 +3656,7 @@ "name": "colorize", "prevSize": 32, "code": 59672, - "tempChar": "" + "tempChar": "" }, { "order": 651, @@ -3568,7 +3664,7 @@ "name": "forward", "prevSize": 32, "code": 59687, - "tempChar": "" + "tempChar": "" }, { "order": 650, @@ -3576,7 +3672,7 @@ "name": "reply", "prevSize": 32, "code": 59719, - "tempChar": "" + "tempChar": "" }, { "order": 487, @@ -3584,7 +3680,7 @@ "name": "help", "prevSize": 32, "code": 59690, - "tempChar": "" + "tempChar": "" }, { "order": 488, @@ -3592,7 +3688,7 @@ "name": "info", "prevSize": 32, "code": 59691, - "tempChar": "" + "tempChar": "" }, { "order": 489, @@ -3600,7 +3696,7 @@ "name": "info-filled", "prevSize": 32, "code": 59675, - "tempChar": "" + "tempChar": "" }, { "order": 490, @@ -3608,7 +3704,7 @@ "name": "delete-filled", "prevSize": 32, "code": 59676, - "tempChar": "" + "tempChar": "" }, { "order": 491, @@ -3616,7 +3712,7 @@ "name": "delete", "prevSize": 32, "code": 59677, - "tempChar": "" + "tempChar": "" }, { "order": 492, @@ -3624,7 +3720,7 @@ "name": "edit", "prevSize": 32, "code": 59683, - "tempChar": "" + "tempChar": "" }, { "order": 493, @@ -3632,7 +3728,7 @@ "name": "new-chat-filled", "prevSize": 32, "code": 59705, - "tempChar": "" + "tempChar": "" }, { "order": 494, @@ -3640,7 +3736,7 @@ "name": "send", "prevSize": 32, "code": 59722, - "tempChar": "" + "tempChar": "" }, { "order": 495, @@ -3648,7 +3744,7 @@ "name": "send-outline", "prevSize": 32, "code": 59723, - "tempChar": "" + "tempChar": "" }, { "order": 496, @@ -3656,7 +3752,7 @@ "name": "add-user-filled", "prevSize": 32, "code": 59652, - "tempChar": "" + "tempChar": "" }, { "order": 497, @@ -3664,7 +3760,7 @@ "name": "add-user", "prevSize": 32, "code": 59653, - "tempChar": "" + "tempChar": "" }, { "order": 498, @@ -3672,7 +3768,7 @@ "name": "delete-user", "prevSize": 32, "code": 59678, - "tempChar": "" + "tempChar": "" }, { "order": 499, @@ -3680,7 +3776,7 @@ "name": "microphone", "prevSize": 32, "code": 59701, - "tempChar": "" + "tempChar": "" }, { "order": 500, @@ -3688,7 +3784,7 @@ "name": "microphone-alt", "prevSize": 32, "code": 59707, - "tempChar": "" + "tempChar": "" }, { "order": 501, @@ -3696,7 +3792,7 @@ "name": "poll", "prevSize": 32, "code": 59704, - "tempChar": "" + "tempChar": "" }, { "order": 502, @@ -3704,7 +3800,7 @@ "name": "revote", "prevSize": 32, "code": 59706, - "tempChar": "" + "tempChar": "" }, { "order": 503, @@ -3712,7 +3808,7 @@ "name": "photo", "prevSize": 32, "code": 59712, - "tempChar": "" + "tempChar": "" }, { "order": 504, @@ -3720,7 +3816,7 @@ "name": "document", "prevSize": 32, "code": 59679, - "tempChar": "" + "tempChar": "" }, { "order": 505, @@ -3728,7 +3824,7 @@ "name": "camera", "prevSize": 32, "code": 59662, - "tempChar": "" + "tempChar": "" }, { "order": 506, @@ -3736,7 +3832,7 @@ "name": "camera-add", "prevSize": 32, "code": 59663, - "tempChar": "" + "tempChar": "" }, { "order": 507, @@ -3744,7 +3840,7 @@ "name": "logout", "prevSize": 32, "code": 59698, - "tempChar": "" + "tempChar": "" }, { "order": 508, @@ -3752,7 +3848,7 @@ "name": "saved-messages", "prevSize": 32, "code": 59720, - "tempChar": "" + "tempChar": "" }, { "order": 509, @@ -3760,7 +3856,7 @@ "name": "settings", "prevSize": 32, "code": 59726, - "tempChar": "" + "tempChar": "" }, { "order": 652, @@ -3768,7 +3864,7 @@ "name": "phone", "prevSize": 32, "code": 59711, - "tempChar": "" + "tempChar": "" }, { "order": 653, @@ -3776,7 +3872,7 @@ "name": "attach", "prevSize": 32, "code": 59657, - "tempChar": "" + "tempChar": "" }, { "order": 512, @@ -3784,7 +3880,7 @@ "name": "copy", "prevSize": 32, "code": 59674, - "tempChar": "" + "tempChar": "" }, { "order": 513, @@ -3792,7 +3888,7 @@ "name": "channel", "prevSize": 32, "code": 59665, - "tempChar": "" + "tempChar": "" }, { "order": 514, @@ -3800,7 +3896,7 @@ "name": "group", "prevSize": 32, "code": 59689, - "tempChar": "" + "tempChar": "" }, { "order": 515, @@ -3808,7 +3904,7 @@ "name": "user", "prevSize": 32, "code": 59737, - "tempChar": "" + "tempChar": "" }, { "order": 516, @@ -3816,7 +3912,7 @@ "name": "non-contacts", "prevSize": 32, "code": 59688, - "tempChar": "" + "tempChar": "" }, { "order": 517, @@ -3824,7 +3920,7 @@ "name": "active-sessions", "prevSize": 32, "code": 59650, - "tempChar": "" + "tempChar": "" }, { "order": 518, @@ -3832,7 +3928,7 @@ "name": "admin", "prevSize": 32, "code": 59654, - "tempChar": "" + "tempChar": "" }, { "order": 519, @@ -3840,7 +3936,7 @@ "name": "download", "prevSize": 32, "code": 59681, - "tempChar": "" + "tempChar": "" }, { "order": 520, @@ -3848,7 +3944,7 @@ "name": "location", "prevSize": 32, "code": 59696, - "tempChar": "" + "tempChar": "" }, { "order": 521, @@ -3856,7 +3952,7 @@ "name": "stop", "prevSize": 32, "code": 59730, - "tempChar": "" + "tempChar": "" }, { "order": 523, @@ -3864,7 +3960,7 @@ "name": "archive", "prevSize": 32, "code": 59656, - "tempChar": "" + "tempChar": "" }, { "order": 524, @@ -3872,7 +3968,7 @@ "name": "unarchive", "prevSize": 32, "code": 59731, - "tempChar": "" + "tempChar": "" }, { "order": 525, @@ -3880,7 +3976,7 @@ "name": "readchats", "prevSize": 32, "code": 59699, - "tempChar": "" + "tempChar": "" }, { "order": 526, @@ -3888,7 +3984,7 @@ "name": "unread", "prevSize": 32, "code": 59735, - "tempChar": "" + "tempChar": "" }, { "order": 654, @@ -3896,7 +3992,7 @@ "name": "message", "prevSize": 32, "code": 59700, - "tempChar": "" + "tempChar": "" }, { "order": 659, @@ -3904,7 +4000,7 @@ "name": "lock", "prevSize": 32, "code": 59697, - "tempChar": "" + "tempChar": "" }, { "order": 529, @@ -3912,7 +4008,7 @@ "name": "unlock", "prevSize": 32, "code": 59732, - "tempChar": "" + "tempChar": "" }, { "order": 530, @@ -3920,7 +4016,7 @@ "name": "mute", "prevSize": 32, "code": 59703, - "tempChar": "" + "tempChar": "" }, { "order": 531, @@ -3928,7 +4024,7 @@ "name": "unmute", "prevSize": 32, "code": 59733, - "tempChar": "" + "tempChar": "" }, { "order": 532, @@ -3936,7 +4032,7 @@ "name": "pin", "prevSize": 32, "code": 59713, - "tempChar": "" + "tempChar": "" }, { "order": 533, @@ -3944,7 +4040,7 @@ "name": "unpin", "prevSize": 32, "code": 59734, - "tempChar": "" + "tempChar": "" }, { "order": 534, @@ -3952,7 +4048,7 @@ "name": "smallscreen", "prevSize": 32, "code": 59742, - "tempChar": "" + "tempChar": "" }, { "order": 535, @@ -3960,7 +4056,7 @@ "name": "fullscreen", "prevSize": 32, "code": 59743, - "tempChar": "" + "tempChar": "" }, { "order": 536, @@ -3968,7 +4064,7 @@ "name": "large-pause", "prevSize": 32, "code": 59694, - "tempChar": "" + "tempChar": "" }, { "order": 537, @@ -3976,7 +4072,7 @@ "name": "large-play", "prevSize": 32, "code": 59695, - "tempChar": "" + "tempChar": "" }, { "order": 538, @@ -3984,7 +4080,7 @@ "name": "pause", "prevSize": 32, "code": 59709, - "tempChar": "" + "tempChar": "" }, { "order": 539, @@ -3992,7 +4088,7 @@ "name": "play", "prevSize": 32, "code": 59715, - "tempChar": "" + "tempChar": "" }, { "order": 540, @@ -4000,7 +4096,7 @@ "name": "channelviews", "prevSize": 32, "code": 59666, - "tempChar": "" + "tempChar": "" }, { "order": 541, @@ -4008,7 +4104,7 @@ "name": "message-succeeded", "prevSize": 32, "code": 59648, - "tempChar": "" + "tempChar": "" }, { "order": 657, @@ -4016,7 +4112,7 @@ "name": "message-read", "prevSize": 32, "code": 59649, - "tempChar": "" + "tempChar": "" }, { "order": 543, @@ -4024,7 +4120,7 @@ "name": "message-pending", "prevSize": 32, "code": 59724, - "tempChar": "" + "tempChar": "" }, { "order": 544, @@ -4032,7 +4128,7 @@ "name": "message-failed", "prevSize": 32, "code": 59725, - "tempChar": "" + "tempChar": "" }, { "order": 545, @@ -4040,7 +4136,7 @@ "name": "favorite", "prevSize": 32, "code": 59710, - "tempChar": "" + "tempChar": "" }, { "order": 546, @@ -4048,7 +4144,7 @@ "name": "keyboard", "prevSize": 32, "code": 59716, - "tempChar": "" + "tempChar": "" }, { "order": 547, @@ -4056,7 +4152,7 @@ "name": "delete-left", "prevSize": 32, "code": 59717, - "tempChar": "" + "tempChar": "" }, { "order": 548, @@ -4064,7 +4160,7 @@ "name": "recent", "prevSize": 32, "code": 59718, - "tempChar": "" + "tempChar": "" }, { "order": 549, @@ -4072,7 +4168,7 @@ "name": "gifs", "prevSize": 32, "code": 59727, - "tempChar": "" + "tempChar": "" }, { "order": 550, @@ -4080,7 +4176,7 @@ "name": "stickers", "prevSize": 32, "code": 59739, - "tempChar": "" + "tempChar": "" }, { "order": 551, @@ -4088,7 +4184,7 @@ "name": "smile", "prevSize": 32, "code": 59728, - "tempChar": "" + "tempChar": "" }, { "order": 552, @@ -4096,7 +4192,7 @@ "name": "animals", "prevSize": 32, "code": 59655, - "tempChar": "" + "tempChar": "" }, { "order": 553, @@ -4104,7 +4200,7 @@ "name": "eats", "prevSize": 32, "code": 59682, - "tempChar": "" + "tempChar": "" }, { "order": 554, @@ -4112,7 +4208,7 @@ "name": "sport", "prevSize": 32, "code": 59729, - "tempChar": "" + "tempChar": "" }, { "order": 555, @@ -4120,7 +4216,7 @@ "name": "car", "prevSize": 32, "code": 59664, - "tempChar": "" + "tempChar": "" }, { "order": 556, @@ -4128,7 +4224,7 @@ "name": "lamp", "prevSize": 32, "code": 59692, - "tempChar": "" + "tempChar": "" }, { "order": 557, @@ -4136,7 +4232,7 @@ "name": "language", "prevSize": 32, "code": 59693, - "tempChar": "" + "tempChar": "" }, { "order": 558, @@ -4144,7 +4240,7 @@ "name": "flag", "prevSize": 32, "code": 59686, - "tempChar": "" + "tempChar": "" }, { "order": 559, @@ -4152,7 +4248,7 @@ "name": "more", "prevSize": 32, "code": 59702, - "tempChar": "" + "tempChar": "" }, { "order": 560, @@ -4160,7 +4256,7 @@ "name": "search", "prevSize": 32, "code": 59721, - "tempChar": "" + "tempChar": "" }, { "order": 561, @@ -4168,7 +4264,7 @@ "name": "remove", "prevSize": 32, "code": 59740, - "tempChar": "" + "tempChar": "" }, { "order": 562, @@ -4176,7 +4272,7 @@ "name": "add", "prevSize": 32, "code": 59651, - "tempChar": "" + "tempChar": "" }, { "order": 563, @@ -4184,7 +4280,7 @@ "name": "check", "prevSize": 32, "code": 59668, - "tempChar": "" + "tempChar": "" }, { "order": 564, @@ -4192,7 +4288,7 @@ "name": "close", "prevSize": 32, "code": 59673, - "tempChar": "" + "tempChar": "" }, { "order": 610, @@ -4200,7 +4296,7 @@ "name": "arrow-left", "prevSize": 32, "code": 59661, - "tempChar": "" + "tempChar": "" }, { "order": 566, @@ -4208,7 +4304,7 @@ "name": "arrow-right", "prevSize": 32, "code": 59708, - "tempChar": "" + "tempChar": "" }, { "order": 730, @@ -4216,7 +4312,7 @@ "name": "down", "prevSize": 32, "code": 59680, - "tempChar": "" + "tempChar": "" }, { "order": 568, @@ -4224,7 +4320,7 @@ "name": "up", "prevSize": 32, "code": 59736, - "tempChar": "" + "tempChar": "" }, { "order": 569, @@ -4232,7 +4328,7 @@ "name": "eye-closed", "prevSize": 32, "code": 59685, - "tempChar": "" + "tempChar": "" }, { "order": 570, @@ -4240,7 +4336,7 @@ "name": "eye", "prevSize": 32, "code": 59684, - "tempChar": "" + "tempChar": "" }, { "order": 571, @@ -4248,7 +4344,7 @@ "name": "muted", "prevSize": 32, "code": 59741, - "tempChar": "" + "tempChar": "" }, { "order": 572, @@ -4256,7 +4352,7 @@ "name": "avatar-archived-chats", "prevSize": 32, "code": 59658, - "tempChar": "" + "tempChar": "" }, { "order": 573, @@ -4264,15 +4360,15 @@ "name": "avatar-deleted-account", "prevSize": 32, "code": 59659, - "tempChar": "" + "tempChar": "" }, { - "order": 574, + "order": 747, "id": 2, "name": "avatar-saved-messages", "prevSize": 32, "code": 59660, - "tempChar": "" + "tempChar": "" }, { "order": 575, @@ -4280,7 +4376,7 @@ "name": "pinned-chat", "prevSize": 32, "code": 59714, - "tempChar": "" + "tempChar": "" } ], "prevSize": 32, diff --git a/src/styles/_common.scss b/src/styles/_common.scss index c39e58d70..bbbce9978 100644 --- a/src/styles/_common.scss +++ b/src/styles/_common.scss @@ -144,6 +144,6 @@ } &.deleted-account { - --color-user: var(--color-gray); + --color-user: #9eaab5; } } diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 31bf64f3b..29afa5abf 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -150,6 +150,7 @@ $color-message-reaction-own-hover: #b5e0a4; --color-chat-hover: #{$color-chat-hover}; --color-chat-active: #{$color-chat-active}; + --color-chat-active-greyed: #60a7f0; --color-item-active: #{$color-item-active}; --color-selection-highlight: #{$color-selection}; @@ -181,6 +182,7 @@ $color-message-reaction-own-hover: #b5e0a4; --border-radius-default-tiny: 0.375rem; --border-radius-messages: 0.75rem; --border-radius-messages-small: 0.375rem; + --border-radius-forum-avatar: 33%; --messages-container-width: 45.5rem; --right-column-width: 26.5rem; --header-height: 3.5rem; @@ -230,6 +232,7 @@ $color-message-reaction-own-hover: #b5e0a4; --z-message-select-area: 8; --z-sticky-date: 9; --z-register-add-avatar: 5; + --z-forum-panel: 5; --z-media-viewer-head: 3; --z-resize-handle: 2; --z-below: -1; diff --git a/src/styles/icons.scss b/src/styles/icons.scss index b5b3ec0d5..e19cfd0ea 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -51,6 +51,18 @@ .icon-volume-3:before { content: "\e991"; } +.icon-forums:before { + content: "\e9b4"; +} +.icon-hashtag:before { + content: "\e9b1"; +} +.icon-reopen-topic:before { + content: "\e9b2"; +} +.icon-close-topic:before { + content: "\e9b3"; +} .icon-open-in-new-tab:before { content: "\e9af"; } diff --git a/src/styles/themes.json b/src/styles/themes.json index e46edfd01..45424b22f 100644 --- a/src/styles/themes.json +++ b/src/styles/themes.json @@ -15,6 +15,7 @@ "--color-background-own-selected": ["#d0ffac", "#6549d4"], "--color-chat-hover": ["#F4F4F5", "#2C2C2C"], "--color-chat-active": ["#3390EC", "#766AC8"], + "--color-chat-active-greyed": ["#60a7f0", "#9288d3"], "--color-item-active": ["#ededed", "#292929"], "--color-text": ["#000000", "#FFFFFF"], "--color-text-secondary": ["#707579", "#AAAAAA"], @@ -49,5 +50,12 @@ "--color-message-reaction-own": ["#cef0ba", "#675CAF"], "--color-message-reaction-hover-own": ["#b5e0a4", "#5B529B"], "--color-voice-transcribe-button": ["#e8f3ff", "#2a2a3c"], - "--color-voice-transcribe-button-own": ["#cceebf", "#8373d3"] + "--color-voice-transcribe-button-own": ["#cceebf", "#8373d3"], + "--color-topic-blue": ["#2F7772", "#6ff9f0"], + "--color-topic-yellow": ["#7F693B", "#ffd67e"], + "--color-topic-violet": ["#8B5A96", "#cb86db"], + "--color-topic-green": ["#44774A", "#8eee98"], + "--color-topic-rose": ["#9B576B", "#ff93b2"], + "--color-topic-red": ["#EB6858", "#fb6f5f"], + "--color-topic-grey": ["#6C6C6C", "#999999"] } diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index 088163dee..c0bc908a2 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -35,11 +35,12 @@ export const processDeepLink = (url: string) => { switch (method) { case 'resolve': { const { - domain, phone, post, comment, voicechat, livestream, start, startattach, attach, + domain, phone, post, comment, voicechat, livestream, start, startattach, attach, thread, topic, } = params; const startAttach = params.hasOwnProperty('startattach') && !startattach ? true : startattach; const choose = parseChooseParameter(params.choose); + const threadId = Number(thread) || Number(topic); if (domain !== 'telegrampassport') { if (startAttach && choose) { @@ -63,6 +64,7 @@ export const processDeepLink = (url: string) => { startParam: start, startAttach, attach, + threadId, }); } } diff --git a/src/util/folderManager.ts b/src/util/folderManager.ts index cdebe082f..a9a07bd4d 100644 --- a/src/util/folderManager.ts +++ b/src/util/folderManager.ts @@ -422,10 +422,19 @@ function buildChatSummary( ): ChatSummary { const { id, type, lastMessage, isRestricted, isNotJoined, migratedTo, folderId, - unreadCount, unreadMentionsCount, hasUnreadMark, - joinDate, draftDate, + unreadCount: chatUnreadCount, unreadMentionsCount: chatUnreadMentionsCount, hasUnreadMark, + joinDate, draftDate, isForum, topics, } = chat; + const { unreadCount, unreadMentionsCount } = isForum + ? Object.values(topics || {}).reduce((acc, topic) => { + acc.unreadCount += topic.unreadCount; + acc.unreadMentionsCount += topic.unreadMentionsCount; + + return acc; + }, { unreadCount: 0, unreadMentionsCount: 0 }) + : { unreadCount: chatUnreadCount, unreadMentionsCount: chatUnreadMentionsCount }; + const userInfo = type === 'chatTypePrivate' && user; const shouldHideServiceChat = chat.id === SERVICE_NOTIFICATIONS_USER_ID && ( !chat.lastMessage || chat.lastMessage.content.action?.type === 'historyClear' diff --git a/src/util/forumColors.ts b/src/util/forumColors.ts new file mode 100644 index 000000000..05044ec21 --- /dev/null +++ b/src/util/forumColors.ts @@ -0,0 +1,27 @@ +import blue from '../assets/icons/forumTopic/blue.svg'; +import green from '../assets/icons/forumTopic/green.svg'; +import grey from '../assets/icons/forumTopic/grey.svg'; +import red from '../assets/icons/forumTopic/red.svg'; +import rose from '../assets/icons/forumTopic/rose.svg'; +import violet from '../assets/icons/forumTopic/violet.svg'; +import yellow from '../assets/icons/forumTopic/yellow.svg'; + +// eslint-disable-next-line max-len +// https://github.com/telegramdesktop/tdesktop/blob/1aece79a471d99a8b63d826b1bce1f36a04d7293/Telegram/SourceFiles/data/data_forum_topic.cpp#L50 +const TOPIC_MAPPING = { + 0x6FB9F0: [blue, 'blue'], + 0xFFD67E: [yellow, 'yellow'], + 0xCB86DB: [violet, 'violet'], + 0x8EEE98: [green, 'green'], + 0xFF93B2: [rose, 'rose'], + 0xFB6F5F: [red, 'red'], +}; + +export function getTopicDefaultIcon(iconColor?: number) { + return (iconColor && TOPIC_MAPPING[iconColor as keyof typeof TOPIC_MAPPING][0]) || grey; +} + +export function getTopicColorCssVariable(iconColor?: number) { + const color = (iconColor && TOPIC_MAPPING[iconColor as keyof typeof TOPIC_MAPPING][1]) || 'grey'; + return `--color-topic-${color}`; +} diff --git a/src/util/notifications.ts b/src/util/notifications.ts index 78c258260..ba1206238 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -23,6 +23,7 @@ import { addNotifyExceptions, replaceSettings } from '../global/reducers'; import { selectChatMessage, selectCurrentMessageList, + selectTopicFromMessage, selectNotifyExceptions, selectNotifySettings, selectUser, @@ -292,6 +293,8 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage, reaction?: A const privateChatUserId = getPrivateChatUserId(chat); const privateChatUser = privateChatUserId ? selectUser(global, privateChatUserId) : undefined; + const topic = selectTopicFromMessage(global, message); + let body: string; if ( !isScreenLocked @@ -308,9 +311,11 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage, reaction?: A actionTargetUsers, actionTargetMessage, actionTargetChatId, + topic, { asPlainText: true }, ) as string; } else { + // TODO[forums] Support ApiChat const senderName = getMessageSenderName(getTranslation, chat.id, messageSender); const summary = getMessageSummaryText(getTranslation, message, false, 60, false); diff --git a/src/util/routing.ts b/src/util/routing.ts index 2a2a48da1..de96991ba 100644 --- a/src/util/routing.ts +++ b/src/util/routing.ts @@ -1,31 +1,46 @@ import type { MessageListType } from '../global/types'; import { MAIN_THREAD_ID } from '../api/types'; import { LOCATION_HASH } from '../hooks/useHistoryBack'; +import { IS_MOCKED_CLIENT } from '../config'; let parsedInitialLocationHash: Record | undefined; let messageHash: string | undefined; let isAlreadyParsed = false; -export const createMessageHash = (chatId: string, type: string, threadId: number): string => ( - chatId.toString() - + (type !== 'thread' ? `_${type}` - : (threadId !== -1 ? `_${threadId}` : '')) -); +export const createLocationHash = (chatId: string, type: string, threadId: number): string => { + const displayType = type === 'thread' ? undefined : type; + const parts = threadId === MAIN_THREAD_ID ? [chatId, displayType] : [chatId, threadId, displayType]; + + return parts.filter(Boolean).join('_'); +}; export function parseLocationHash() { parseInitialLocationHash(); if (!messageHash) return undefined; - const [chatId, typeOrThreadId] = messageHash.split('_'); + const parts = messageHash.split('_'); + let chatId: string | undefined; + let type: string | undefined; + let threadId: string | undefined; + if (parts.length === 1) { + chatId = parts[0]; + } else if (parts.length === 2) { + const isType = ['thread', 'pinned', 'scheduled'].includes(parts[1]); + chatId = parts[0]; + type = isType ? parts[1] : 'thread'; + threadId = !isType ? parts[1] : undefined; + } else if (parts.length >= 3) { + [chatId, threadId, type] = parts; + } if (!chatId?.match(/^-?\d+$/)) return undefined; - const isType = ['thread', 'pinned', 'scheduled'].includes(typeOrThreadId); + const isType = ['thread', 'pinned', 'scheduled'].includes(type!); return { chatId, - type: Boolean(typeOrThreadId) && isType ? (typeOrThreadId as MessageListType) : 'thread', - threadId: Boolean(typeOrThreadId) && !isType ? Number(typeOrThreadId) : MAIN_THREAD_ID, + type: type && isType ? (type as MessageListType) : 'thread', + threadId: Number(threadId) || MAIN_THREAD_ID, }; } @@ -39,9 +54,13 @@ export function parseInitialLocationHash() { let parsedHash = LOCATION_HASH ? LOCATION_HASH.replace(/^#/, '') : undefined; if (parsedHash?.includes('?')) { [messageHash, parsedHash] = parsedHash.split('?'); - window.location.hash = messageHash; + if (!IS_MOCKED_CLIENT) { + window.location.hash = messageHash; + } } else if (parsedHash?.includes('=')) { - window.location.hash = ''; + if (!IS_MOCKED_CLIENT) { + window.location.hash = ''; + } } parsedInitialLocationHash = parsedHash?.includes('=') ? parsedHash?.split('&').reduce((acc, cur) => {