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 40b6fdb59..a9fcf97cd 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index ba6fc3562..599c93bcc 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ 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 000000000..2f316b92d Binary files /dev/null and b/src/lib/gramjs/client/__data__/Cumshot.tgs differ diff --git a/src/lib/gramjs/client/__data__/Eggplant.tgs b/src/lib/gramjs/client/__data__/Eggplant.tgs new file mode 100644 index 000000000..b0412f5c0 Binary files /dev/null and b/src/lib/gramjs/client/__data__/Eggplant.tgs differ diff --git a/src/lib/gramjs/client/__data__/Peach.tgs b/src/lib/gramjs/client/__data__/Peach.tgs new file mode 100644 index 000000000..78d40f9f3 Binary files /dev/null and b/src/lib/gramjs/client/__data__/Peach.tgs differ diff --git a/src/lib/gramjs/client/__data__/lock.png b/src/lib/gramjs/client/__data__/lock.png new file mode 100644 index 000000000..a9789c1f7 Binary files /dev/null and b/src/lib/gramjs/client/__data__/lock.png differ 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) => {