From c3c71cbc9e4ce255dc60887623ef42c2ea73fc64 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 6 Feb 2024 16:48:54 +0100 Subject: [PATCH] Introduce Saved dialogs (#4177) --- src/api/gramjs/ChatAbortController.ts | 10 +- src/api/gramjs/apiBuilders/appConfig.ts | 4 +- src/api/gramjs/apiBuilders/chats.ts | 14 + src/api/gramjs/apiBuilders/messages.ts | 17 +- src/api/gramjs/methods/chats.ts | 171 +++++++- src/api/gramjs/methods/client.ts | 5 +- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/messages.ts | 50 ++- src/api/gramjs/methods/users.ts | 2 + src/api/gramjs/updater.ts | 20 + src/api/types/chats.ts | 6 +- src/api/types/messages.ts | 9 +- src/api/types/updates.ts | 25 +- src/assets/font-icons/author-hidden.svg | 1 + src/assets/font-icons/my-notes.svg | 1 + src/components/common/Avatar.tsx | 65 ++- src/components/common/ChatExtra.tsx | 21 +- src/components/common/ChatOrUserPicker.tsx | 3 +- src/components/common/Composer.tsx | 6 +- src/components/common/FullNameTitle.tsx | 28 +- src/components/common/GroupChatInfo.tsx | 45 +- src/components/common/Icon.tsx | 9 +- src/components/common/PrivateChatInfo.tsx | 19 + src/components/common/ProfileInfo.tsx | 25 +- src/components/common/ProfilePhoto.tsx | 40 +- src/components/common/RecipientPicker.tsx | 7 +- src/components/common/helpers/sortChatIds.ts | 39 ++ src/components/left/main/Chat.tsx | 78 +++- src/components/left/main/ChatBadge.tsx | 14 +- src/components/left/main/ChatList.tsx | 46 +- src/components/left/main/EmptyFolder.tsx | 2 +- src/components/left/main/ForumPanel.tsx | 2 +- .../left/main/hooks/useChatListEntry.tsx | 11 +- src/components/left/newChat/NewChatStep1.tsx | 12 +- src/components/left/search/ChatResults.tsx | 9 +- src/components/main/DraftRecipientPicker.tsx | 3 +- .../main/ForwardRecipientPicker.tsx | 6 +- src/components/main/Main.tsx | 5 +- .../main/premium/PremiumFeatureModal.tsx | 4 +- .../common/PremiumLimitReachedModal.tsx | 4 + src/components/mediaViewer/MediaViewer.tsx | 4 +- .../mediaViewer/MediaViewerContent.tsx | 6 +- .../mediaViewer/MediaViewerSlides.tsx | 4 +- src/components/middle/ActionMessage.tsx | 4 +- src/components/middle/ContactGreeting.tsx | 8 +- src/components/middle/HeaderActions.tsx | 8 +- src/components/middle/HeaderMenuContainer.tsx | 6 +- src/components/middle/MessageList.scss | 3 +- src/components/middle/MessageList.tsx | 41 +- src/components/middle/MessageListContent.tsx | 12 +- src/components/middle/MiddleColumn.scss | 10 +- src/components/middle/MiddleColumn.tsx | 55 ++- src/components/middle/MiddleHeader.scss | 11 + src/components/middle/MiddleHeader.tsx | 42 +- src/components/middle/MobileSearch.tsx | 3 +- .../middle/composer/AttachBotItem.tsx | 4 +- src/components/middle/composer/AttachMenu.tsx | 4 +- .../middle/composer/AttachmentModal.tsx | 3 +- .../middle/composer/MessageInput.tsx | 4 +- .../middle/composer/StickerPicker.tsx | 4 +- .../middle/composer/StickerTooltip.tsx | 3 +- src/components/middle/composer/SymbolMenu.tsx | 3 +- .../middle/composer/SymbolMenuButton.tsx | 4 +- .../middle/composer/WebPagePreview.tsx | 4 +- .../middle/composer/hooks/useDraft.ts | 5 +- .../middle/composer/hooks/useEditing.ts | 3 +- .../middle/helpers/groupMessages.ts | 2 +- .../middle/hooks/usePinnedMessage.ts | 4 +- src/components/middle/message/Message.tsx | 33 +- src/components/middle/message/MessageMeta.tsx | 13 +- .../middle/message/hooks/useInnerHandlers.ts | 4 +- src/components/right/AddChatMembers.tsx | 14 +- src/components/right/Profile.scss | 6 +- src/components/right/Profile.tsx | 86 ++-- src/components/right/RightColumn.tsx | 45 +- src/components/right/RightHeader.scss | 16 +- src/components/right/RightHeader.tsx | 120 +++-- src/components/right/RightSearch.tsx | 30 +- src/components/right/hooks/useProfileState.ts | 31 +- .../right/hooks/useProfileViewportIds.ts | 26 +- .../right/management/ManageGroupMembers.tsx | 5 +- src/config.ts | 4 +- src/global/actions/api/bots.ts | 28 +- src/global/actions/api/chats.ts | 229 +++++++--- src/global/actions/api/localSearch.ts | 40 +- src/global/actions/api/messages.ts | 42 +- src/global/actions/api/reactions.ts | 5 +- src/global/actions/api/sync.ts | 44 +- src/global/actions/apiUpdaters/chats.ts | 39 ++ src/global/actions/apiUpdaters/messages.ts | 101 +++-- src/global/actions/apiUpdaters/users.ts | 9 +- src/global/actions/ui/messages.ts | 20 +- src/global/cache.ts | 26 +- src/global/helpers/chats.ts | 55 +-- src/global/helpers/localSearch.ts | 4 +- src/global/helpers/users.ts | 4 +- src/global/init.ts | 2 +- src/global/initialState.ts | 1 + src/global/reducers/chats.ts | 63 ++- src/global/reducers/localSearch.ts | 16 +- src/global/reducers/messages.ts | 34 +- src/global/reducers/stories.ts | 5 +- src/global/selectors/chats.ts | 31 +- src/global/selectors/management.ts | 2 + src/global/selectors/messages.ts | 149 ++++--- src/global/types.ts | 78 ++-- src/hooks/scroll/useTopOverscroll.tsx | 3 +- src/hooks/useChatContextActions.ts | 49 ++- src/hooks/useSendMessageAction.ts | 3 +- src/lib/gramjs/tl/apiTl.js | 4 + src/lib/gramjs/tl/static/api.json | 4 + src/styles/_common.scss | 1 + src/styles/icons.scss | 414 +++++++++--------- src/styles/icons.woff | Bin 28060 -> 28476 bytes src/styles/icons.woff2 | Bin 23560 -> 23852 bytes src/types/icons/font.ts | 2 + src/types/index.ts | 6 +- src/util/deepLinkParser.ts | 6 +- src/util/folderManager.ts | 145 ++++-- src/util/routing.ts | 11 +- 120 files changed, 2185 insertions(+), 1027 deletions(-) create mode 100644 src/assets/font-icons/author-hidden.svg create mode 100644 src/assets/font-icons/my-notes.svg create mode 100644 src/components/common/helpers/sortChatIds.ts diff --git a/src/api/gramjs/ChatAbortController.ts b/src/api/gramjs/ChatAbortController.ts index 714d4ca6c..df088dce9 100644 --- a/src/api/gramjs/ChatAbortController.ts +++ b/src/api/gramjs/ChatAbortController.ts @@ -1,7 +1,9 @@ -export class ChatAbortController extends AbortController { - private threads = new Map(); +import type { ThreadId } from '../../types'; - public getThreadSignal(threadId: number): AbortSignal { +export class ChatAbortController extends AbortController { + private threads = new Map(); + + public getThreadSignal(threadId: ThreadId): AbortSignal { let controller = this.threads.get(threadId); if (!controller) { controller = new AbortController(); @@ -10,7 +12,7 @@ export class ChatAbortController extends AbortController { return controller.signal; } - public abortThread(threadId: number, reason?: string): void { + public abortThread(threadId: ThreadId, reason?: string): void { this.threads.get(threadId)?.abort(reason); this.threads.delete(threadId); } diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 716ae68c3..ffc39247e 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -29,7 +29,8 @@ type Limit = | 'about_length_limit' | 'chatlist_invites_limit' | 'chatlist_joined_limit' - | 'recommended_channels_limit'; + | 'recommended_channels_limit' + | 'saved_dialogs_pinned_limit'; type LimitKey = `${Limit}_${LimitType}`; type LimitsConfig = Record; @@ -124,6 +125,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp chatlistInvites: getLimit(appConfig, 'chatlist_invites_limit', 'chatlistInvites'), chatlistJoined: getLimit(appConfig, 'chatlist_joined_limit', 'chatlistJoined'), recommendedChannels: getLimit(appConfig, 'recommended_channels_limit', 'recommendedChannels'), + savedDialogsPinned: getLimit(appConfig, 'saved_dialogs_pinned_limit', 'savedDialogsPinned'), }, hash, areStoriesHidden: appConfig.stories_all_hidden, diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 27f7dcaa4..2928aad4a 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -126,6 +126,20 @@ export function buildApiChatFromDialog( }; } +export function buildApiChatFromSavedDialog( + dialog: GramJs.SavedDialog, + peerEntity: GramJs.TypeUser | GramJs.TypeChat, +): ApiChat { + const { peer } = dialog; + + return { + id: getApiChatIdFromMtpPeer(peer), + type: getApiChatTypeFromPeerEntity(peerEntity), + title: getApiChatTitleFromMtpPeer(peer, peerEntity), + ...buildApiChatFieldsFromPeerEntity(peerEntity), + }; +} + function buildApiChatPermissions(peerEntity: GramJs.TypeUser | GramJs.TypeChat): { adminRights?: ApiChatAdminRights; currentUserBannedRights?: ApiChatBannedRights; diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 8f1fe5d88..c08988166 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -266,13 +266,15 @@ function buildApiMessageForwardInfo(fwdFrom: GramJs.MessageFwdHeader, isChatWith return { date: fwdFrom.date, + savedDate: fwdFrom.savedDate, isImported: fwdFrom.imported, isChannelPost: Boolean(fwdFrom.channelPost), channelPostId: fwdFrom.channelPost, isLinkedChannelPost: Boolean(fwdFrom.channelPost && savedFromPeerId && !isChatWithSelf), + savedFromPeerId, + fromId, fromChatId: savedFromPeerId || fromId, fromMessageId: fwdFrom.savedFromMsgId || fwdFrom.channelPost, - senderUserId: fromId, hiddenUserName: fwdFrom.fromName, postAuthorTitle: fwdFrom.postAuthor, }; @@ -747,6 +749,7 @@ function buildNewPoll(poll: ApiNewPoll, localId: number) { export function buildLocalMessage( chat: ApiChat, + lastMessageId?: number, text?: string, entities?: ApiMessageEntity[], replyInfo?: ApiInputReplyInfo, @@ -760,7 +763,7 @@ export function buildLocalMessage( sendAs?: ApiPeer, story?: ApiStory | ApiStorySkipped, ): ApiMessage { - const localId = getNextLocalMessageId(chat.lastMessage?.id); + const localId = getNextLocalMessageId(lastMessageId); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; @@ -811,6 +814,7 @@ export function buildLocalForwardedMessage({ noAuthors, noCaptions, isCurrentUserPremium, + lastMessageId, }: { toChat: ApiChat; toThreadId?: number; @@ -819,8 +823,9 @@ export function buildLocalForwardedMessage({ noAuthors?: boolean; noCaptions?: boolean; isCurrentUserPremium?: boolean; + lastMessageId?: number; }): ApiMessage { - const localId = getNextLocalMessageId(toChat?.lastMessage?.id); + const localId = getNextLocalMessageId(lastMessageId); const { content, chatId: fromChatId, @@ -874,11 +879,13 @@ export function buildLocalForwardedMessage({ // Forward info doesn't get added when users forwards his own messages, also when forwarding audio ...(message.chatId !== currentUserId && !isAudio && !noAuthors && { forwardInfo: { - date: message.date, + date: message.forwardInfo?.date || message.date, + savedDate: message.date, isChannelPost: false, fromChatId, fromMessageId, - senderUserId: senderId, + fromId: senderId, + savedFromPeerId: message.chatId, }, }), ...(message.chatId === currentUserId && !noAuthors && { forwardInfo: message.forwardInfo }), diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 54c8925d9..e484f145c 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -37,6 +37,7 @@ import { buildApiChatFolderFromSuggested, buildApiChatFromDialog, buildApiChatFromPreview, + buildApiChatFromSavedDialog, buildApiChatlistExportedInvite, buildApiChatlistInvite, buildApiChatReactions, @@ -84,6 +85,18 @@ type FullChatData = { isForumAsMessages?: true; }; +type ChatListData = { + chatIds: string[]; + chats: ApiChat[]; + users: ApiUser[]; + userStatusesById: Record; + draftsById: Record; + orderedPinnedIds: string[] | undefined; + totalChatCount: number; + messages: ApiMessage[]; + lastMessageByChatId: Record; +}; + let onUpdate: OnApiUpdate; export function init(_onUpdate: OnApiUpdate) { @@ -95,19 +108,18 @@ export async function fetchChats({ offsetDate, archived, withPinned, - lastLocalServiceMessage, + lastLocalServiceMessageId, }: { limit: number; offsetDate?: number; archived?: boolean; withPinned?: boolean; - lastLocalServiceMessage?: ApiMessage; -}) { + lastLocalServiceMessageId?: number; +}): Promise { const result = await invokeRequest(new GramJs.messages.GetDialogs({ offsetPeer: new GramJs.InputPeerEmpty(), limit, offsetDate, - folderId: archived ? ARCHIVED_FOLDER_ID : undefined, ...(withPinned && { excludePinned: true }), })); const resultPinned = withPinned @@ -125,12 +137,10 @@ export async function fetchChats({ } updateLocalDb(result); - const lastMessagesByChatId = buildCollectionByKey( - (resultPinned ? resultPinned.messages : []).concat(result.messages) - .map(buildApiMessage) - .filter(Boolean), - 'chatId', - ); + const messages = (resultPinned ? resultPinned.messages : []) + .concat(result.messages) + .map(buildApiMessage) + .filter(Boolean); dispatchThreadInfoUpdates(result.messages); @@ -145,6 +155,7 @@ export async function fetchChats({ const dialogs = (resultPinned ? resultPinned.dialogs : []).concat(result.dialogs); const orderedPinnedIds: string[] = []; + const lastMessageByChatId: Record = {}; dialogs.forEach((dialog) => { if ( @@ -158,6 +169,7 @@ export async function fetchChats({ const peerEntity = peersByKey[getPeerKey(dialog.peer)]; const chat = buildApiChatFromDialog(dialog, peerEntity); + lastMessageByChatId[chat.id] = dialog.topMessage; if (dialog.pts) { updateChannelState(chat.id, dialog.pts); @@ -165,12 +177,10 @@ export async function fetchChats({ if ( chat.id === SERVICE_NOTIFICATIONS_USER_ID - && lastLocalServiceMessage - && (!lastMessagesByChatId[chat.id] || lastLocalServiceMessage.date > lastMessagesByChatId[chat.id].date) + && lastLocalServiceMessageId + && (lastLocalServiceMessageId > dialog.topMessage) ) { - chat.lastMessage = lastLocalServiceMessage; - } else { - chat.lastMessage = lastMessagesByChatId[chat.id]; + lastMessageByChatId[chat.id] = lastLocalServiceMessageId; } chat.isListed = true; @@ -210,6 +220,96 @@ export async function fetchChats({ draftsById, orderedPinnedIds: withPinned ? orderedPinnedIds : undefined, totalChatCount, + lastMessageByChatId, + messages, + }; +} + +export async function fetchSavedChats({ + limit, + offsetDate, + withPinned, +}: { + limit: number; + offsetDate?: number; + withPinned?: boolean; +}): Promise { + const result = await invokeRequest(new GramJs.messages.GetSavedDialogs({ + offsetPeer: new GramJs.InputPeerEmpty(), + limit, + offsetDate, + ...(withPinned && { excludePinned: true }), + })); + const resultPinned = withPinned + ? await invokeRequest(new GramJs.messages.GetPinnedSavedDialogs()) + : undefined; + + if (!result || result instanceof GramJs.messages.SavedDialogsNotModified) { + return undefined; + } + + const hasPinned = resultPinned && !(resultPinned instanceof GramJs.messages.SavedDialogsNotModified); + + if (hasPinned) { + updateLocalDb(resultPinned); + } + updateLocalDb(result); + + dispatchThreadInfoUpdates(result.messages); + + const messages = (hasPinned ? resultPinned.messages : []) + .concat(result.messages) + .map(buildApiMessage) + .filter(Boolean); + + const peersByKey = preparePeers(result); + if (hasPinned) { + Object.assign(peersByKey, preparePeers(resultPinned, peersByKey)); + } + + const dialogs = (hasPinned ? resultPinned.dialogs : []).concat(result.dialogs); + + const chatIds: string[] = []; + const orderedPinnedIds: string[] = []; + const lastMessageByChatId: Record = {}; + + const chats: ApiChat[] = []; + + dialogs.forEach((dialog) => { + const peerEntity = peersByKey[getPeerKey(dialog.peer)]; + const chat = buildApiChatFromSavedDialog(dialog, peerEntity); + const chatId = getApiChatIdFromMtpPeer(dialog.peer); + + chatIds.push(chatId); + if (withPinned && dialog.pinned) { + orderedPinnedIds.push(chatId); + } + + lastMessageByChatId[chatId] = dialog.topMessage; + + chats.push(chat); + }); + + const { users, userStatusesById } = buildApiUsersAndStatuses((hasPinned ? resultPinned.users : []) + .concat(result.users)); + + let totalChatCount: number; + if (result instanceof GramJs.messages.SavedDialogsSlice) { + totalChatCount = result.count; + } else { + totalChatCount = chatIds.length; + } + + return { + chatIds, + chats, + users, + userStatusesById, + orderedPinnedIds: withPinned ? orderedPinnedIds : undefined, + totalChatCount, + lastMessageByChatId, + messages, + draftsById: {}, }; } @@ -348,10 +448,7 @@ export async function requestChatUpdate({ ? lastLocalMessage : lastRemoteMessage; - const chatUpdate = { - ...buildApiChatFromDialog(dialog, peerEntity), - ...(!noLastMessage && { lastMessage }), - }; + const chatUpdate = buildApiChatFromDialog(dialog, peerEntity); onUpdate({ '@type': 'updateChat', @@ -359,6 +456,14 @@ export async function requestChatUpdate({ chat: chatUpdate, }); + if (!noLastMessage && lastMessage) { + onUpdate({ + '@type': 'updateChatLastMessage', + id, + lastMessage, + }); + } + applyState(result.state); scheduleMutedChatUpdate(chatUpdate.id, chatUpdate.muteUntil, onUpdate); @@ -859,6 +964,30 @@ export async function toggleChatPinned({ } } +export async function toggleSavedDialogPinned({ + chat, shouldBePinned, +}: { + chat: ApiChat; + shouldBePinned: boolean; +}) { + const { id, accessHash } = chat; + + const isActionSuccessful = await invokeRequest(new GramJs.messages.ToggleSavedDialogPin({ + peer: new GramJs.InputDialogPeer({ + peer: buildInputPeer(id, accessHash), + }), + pinned: shouldBePinned || undefined, + })); + + if (isActionSuccessful) { + onUpdate({ + '@type': 'updateSavedDialogPinned', + id: chat.id, + isPinned: shouldBePinned, + }); + } +} + export function toggleChatArchived({ chat, folderId, }: { @@ -1409,7 +1538,8 @@ export function toggleJoinRequest(chat: ApiChat, isEnabled: boolean) { } function preparePeers( - result: GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs, + result: GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs | + GramJs.messages.SavedDialogs | GramJs.messages.SavedDialogsSlice, currentStore?: Record, ) { const store: Record = {}; @@ -1439,6 +1569,7 @@ function preparePeers( function updateLocalDb(result: ( GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs | + GramJs.messages.SavedDialogs | GramJs.messages.SavedDialogsSlice | GramJs.messages.ChatFull | GramJs.contacts.Found | GramJs.contacts.ResolvedPeer | GramJs.channels.ChannelParticipants | GramJs.messages.Chats | GramJs.messages.ChatsSlice | GramJs.TypeUpdates | GramJs.messages.ForumTopics diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index c8296c5af..e43e6fbc5 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -6,6 +6,7 @@ import type { TwoFaParams } from '../../../lib/gramjs/client/2fa'; import TelegramClient from '../../../lib/gramjs/client/TelegramClient'; import { Logger as GramJsLogger } from '../../../lib/gramjs/extensions/index'; +import type { ThreadId } from '../../../types'; import type { ApiInitialArgs, ApiMediaFormat, @@ -222,7 +223,7 @@ type InvokeRequestParams = { dcId?: number; shouldIgnoreErrors?: boolean; abortControllerChatId?: string; - abortControllerThreadId?: number; + abortControllerThreadId?: ThreadId; abortControllerGroup?: 'call'; shouldRetryOnTimeout?: boolean; }; @@ -351,7 +352,7 @@ export function getTmpPassword(currentPassword: string, ttl?: number) { return client.getTmpPassword(currentPassword, ttl); } -export function abortChatRequests(params: { chatId: string; threadId?: number }) { +export function abortChatRequests(params: { chatId: string; threadId?: ThreadId }) { const { chatId, threadId } = params; const controller = CHAT_ABORT_CONTROLLERS.get(chatId); if (!threadId) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 7a33aeb3e..12b9498c8 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -24,7 +24,7 @@ export { editTopic, toggleForum, fetchTopicById, createTopic, toggleParticipantsHidden, checkChatlistInvite, joinChatlistInvite, createChalistInvite, editChatlistInvite, deleteChatlistInvite, fetchChatlistInvites, fetchLeaveChatlistSuggestions, leaveChatlist, togglePeerTranslations, setViewForumAsMessages, - fetchChannelRecommendations, + fetchChannelRecommendations, fetchSavedChats, toggleSavedDialogPinned, } from './chats'; export { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 0002e1b15..08c73e562 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1,5 +1,6 @@ import { Api as GramJs } from '../../../lib/gramjs'; +import type { ThreadId } from '../../../types'; import type { ApiAttachment, ApiChat, @@ -106,21 +107,25 @@ export async function fetchMessages({ chat, threadId, offsetId, + isSavedDialog, ...pagination }: { chat: ApiChat; - threadId?: number; + threadId?: ThreadId; offsetId?: number; + isSavedDialog?: boolean; addOffset?: number; limit: number; }) { - const RequestClass = threadId === MAIN_THREAD_ID ? GramJs.messages.GetHistory : GramJs.messages.GetReplies; + const RequestClass = threadId === MAIN_THREAD_ID + ? GramJs.messages.GetHistory : isSavedDialog + ? GramJs.messages.GetSavedHistory : GramJs.messages.GetReplies; let result; try { result = await invokeRequest(new RequestClass({ peer: buildInputPeer(chat.id, chat.accessHash), - ...(threadId !== MAIN_THREAD_ID && { + ...(threadId !== MAIN_THREAD_ID && !isSavedDialog && { msgId: Number(threadId), }), ...(offsetId && { @@ -241,6 +246,7 @@ let mediaQueue = Promise.resolve(); export function sendMessage( { chat, + lastMessageId, text, entities, replyInfo, @@ -281,6 +287,7 @@ export function sendMessage( ) { const localMessage = buildLocalMessage( chat, + lastMessageId, text, entities, replyInfo, @@ -707,10 +714,10 @@ export async function pinMessage({ })); } -export async function unpinAllMessages({ chat, threadId }: { chat: ApiChat; threadId?: number }) { +export async function unpinAllMessages({ chat, threadId }: { chat: ApiChat; threadId?: ThreadId }) { await invokeRequest(new GramJs.messages.UnpinAllMessages({ peer: buildInputPeer(chat.id, chat.accessHash), - ...(threadId && { topMsgId: threadId }), + ...(threadId && { topMsgId: Number(threadId) }), })); } @@ -806,7 +813,7 @@ export async function reportMessages({ export async function sendMessageAction({ peer, threadId, action, }: { - peer: ApiPeer; threadId?: number; action: ApiSendMessageAction; + peer: ApiPeer; threadId?: ThreadId; action: ApiSendMessageAction; }) { const gramAction = buildSendMessageAction(action); if (!gramAction) { @@ -820,7 +827,7 @@ export async function sendMessageAction({ try { const result = await invokeRequest(new GramJs.messages.SetTyping({ peer: buildInputPeer(peer.id, peer.accessHash), - topMsgId: threadId, + topMsgId: Number(threadId), action: gramAction, }), { shouldThrow: true, @@ -837,7 +844,7 @@ export async function sendMessageAction({ export async function markMessageListRead({ chat, threadId, maxId = 0, }: { - chat: ApiChat; threadId: number; maxId?: number; + chat: ApiChat; threadId: ThreadId; maxId?: number; }) { const isChannel = getEntityTypeById(chat.id) === 'channel'; @@ -851,7 +858,7 @@ export async function markMessageListRead({ } else if (isChannel) { await invokeRequest(new GramJs.messages.ReadDiscussion({ peer: buildInputPeer(chat.id, chat.accessHash), - msgId: threadId, + msgId: Number(threadId), readMaxId: fixedMaxId, })); } else { @@ -999,12 +1006,13 @@ export async function fetchDiscussionMessage({ } export async function searchMessagesLocal({ - chat, type, query, threadId, minDate, maxDate, ...pagination + chat, isSavedDialog, type, query, threadId, minDate, maxDate, ...pagination }: { chat: ApiChat; + isSavedDialog?: boolean; type?: ApiMessageSearchType | ApiGlobalMessageSearchType; query?: string; - threadId?: number; + threadId?: ThreadId; offsetId?: number; addOffset?: number; limit: number; @@ -1037,9 +1045,12 @@ export async function searchMessagesLocal({ } } + const peer = buildInputPeer(chat.id, chat.accessHash); + const result = await invokeRequest(new GramJs.messages.Search({ - peer: buildInputPeer(chat.id, chat.accessHash), - topMsgId: threadId === MAIN_THREAD_ID ? undefined : threadId, + peer: isSavedDialog ? new GramJs.InputPeerSelf() : peer, + savedPeerId: isSavedDialog ? peer : undefined, + topMsgId: threadId !== MAIN_THREAD_ID && !isSavedDialog ? Number(threadId) : undefined, filter, q: query || '', minDate, @@ -1288,10 +1299,11 @@ export async function forwardMessages({ noCaptions, isCurrentUserPremium, wasDrafted, + lastMessageId, }: { fromChat: ApiChat; toChat: ApiChat; - toThreadId?: number; + toThreadId?: ThreadId; messages: ApiMessage[]; isSilent?: boolean; scheduledAt?: number; @@ -1301,6 +1313,7 @@ export async function forwardMessages({ noCaptions?: boolean; isCurrentUserPremium?: boolean; wasDrafted?: boolean; + lastMessageId?: number; }) { const messageIds = messages.map(({ id }) => id); const randomIds = messages.map(generateRandomBigInt); @@ -1309,12 +1322,13 @@ export async function forwardMessages({ messages.forEach((message, index) => { const localMessage = buildLocalForwardedMessage({ toChat, - toThreadId, + toThreadId: Number(toThreadId), message, scheduledAt, noAuthors, noCaptions, isCurrentUserPremium, + lastMessageId, }); localMessages[randomIds[index].toString()] = localMessage; @@ -1337,7 +1351,7 @@ export async function forwardMessages({ silent: isSilent || undefined, dropAuthor: noAuthors || undefined, dropMediaCaptions: noCaptions || undefined, - ...(toThreadId && { topMsgId: toThreadId }), + ...(toThreadId && { topMsgId: Number(toThreadId) }), ...(scheduledAt && { scheduleDate: scheduledAt }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), }), { @@ -1434,14 +1448,14 @@ function updateLocalDb(result: ( }); } -export async function fetchPinnedMessages({ chat, threadId }: { chat: ApiChat; threadId: number }) { +export async function fetchPinnedMessages({ chat, threadId }: { chat: ApiChat; threadId: ThreadId }) { 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, + topMsgId: Number(threadId), }, ), { abortControllerChatId: chat.id, diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index bfeb4ff8a..ad6da1a4c 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -267,6 +267,8 @@ export async function fetchProfilePhotos(user?: ApiUser, chat?: ApiChat) { }; } + if (chat?.isRestricted) return undefined; + const result = await searchMessagesLocal({ chat: chat!, type: 'profilePhoto', diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index f558c6d86..c1b7b7751 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -597,6 +597,26 @@ export function updater(update: Update) { ids, folderId: update.folderId || undefined, }); + } else if ( + update instanceof GramJs.UpdateSavedDialogPinned + && update.peer instanceof GramJs.DialogPeer + ) { + onUpdate({ + '@type': 'updateSavedDialogPinned', + id: getApiChatIdFromMtpPeer(update.peer.peer), + isPinned: update.pinned || false, + }); + } else if (update instanceof GramJs.UpdatePinnedSavedDialogs) { + const ids = update.order + ? update.order + .filter((dp): dp is GramJs.DialogPeer => dp instanceof GramJs.DialogPeer) + .map((dp) => getApiChatIdFromMtpPeer(dp.peer)) + : []; + + onUpdate({ + '@type': 'updatePinnedSavedDialogIds', + ids, + }); } else if (update instanceof GramJs.UpdateFolderPeers) { update.folderPeers.forEach((folderPeer) => { const { folderId, peer } = folderPeer; diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 4dddfc705..5575da6c5 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -1,6 +1,7 @@ +import type { ThreadId } from '../../types'; import type { ApiBotCommand } from './bots'; import type { - ApiChatReactions, ApiMessage, ApiPhoto, ApiStickerSet, + ApiChatReactions, ApiPhoto, ApiStickerSet, } from './messages'; import type { ApiChatInviteImporter } from './misc'; import type { @@ -21,7 +22,6 @@ export interface ApiChat { type: ApiChatType; title: string; hasUnreadMark?: boolean; - lastMessage?: ApiMessage; lastReadOutboxMessageId?: number; lastReadInboxMessageId?: number; unreadCount?: number; @@ -48,7 +48,7 @@ export interface ApiChat { emojiStatus?: ApiEmojiStatus; isForum?: boolean; isForumAsMessages?: true; - topics?: Record; + topics?: Record; listedTopicIds?: number[]; topicsCount?: number; orderedPinnedTopicIds?: number[]; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index c02f0bcb5..6ae8059b9 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1,3 +1,4 @@ +import type { ThreadId } from '../../types'; import type { ApiWebDocument } from './bots'; import type { ApiGroupCall, PhoneCallAction } from './calls'; import type { ApiChat } from './chats'; @@ -370,12 +371,14 @@ export type ApiInputReplyInfo = ApiInputMessageReplyInfo | ApiInputStoryReplyInf export interface ApiMessageForwardInfo { date: number; + savedDate?: number; isImported?: boolean; isChannelPost: boolean; channelPostId?: number; isLinkedChannelPost?: boolean; fromChatId?: string; - senderUserId?: string; + fromId?: string; + savedFromPeerId?: string; fromMessageId?: number; hiddenUserName?: string; postAuthorTitle?: string; @@ -595,14 +598,14 @@ interface ApiBaseThreadInfo { export interface ApiCommentsInfo extends ApiBaseThreadInfo { isCommentsInfo: true; - threadId?: number; + threadId?: ThreadId; originChannelId: string; originMessageId: number; } export interface ApiMessageThreadInfo extends ApiBaseThreadInfo { isCommentsInfo: false; - threadId: number; + threadId: ThreadId; // For linked messages in discussion fromChannelId?: string; fromMessageId?: number; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index ce7354b4e..0934e901d 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -6,7 +6,7 @@ import type { VideoRotation, VideoState, } from '../../lib/secret-sauce'; -import type { ApiPrivacyKey, PrivacyVisibility } from '../../types'; +import type { ApiPrivacyKey, PrivacyVisibility, ThreadId } from '../../types'; import type { ApiBotMenuButton } from './bots'; import type { ApiGroupCall, ApiPhoneCall, @@ -102,6 +102,12 @@ export type ApiUpdateChat = { noTopChatsRequest?: boolean; }; +export type ApiUpdateChatLastMessage = { + '@type': 'updateChatLastMessage'; + id: string; + lastMessage: ApiMessage; +}; + export type ApiUpdateChatJoin = { '@type': 'updateChatJoin'; id: string; @@ -126,7 +132,7 @@ export type ApiUpdateChatInbox = { export type ApiUpdateChatTypingStatus = { '@type': 'updateChatTypingStatus'; id: string; - threadId?: number; + threadId?: ThreadId; typingStatus: ApiTypingStatus | undefined; }; @@ -170,6 +176,17 @@ export type ApiUpdateChatPinned = { isPinned: boolean; }; +export type ApiUpdatePinnedSavedDialogIds = { + '@type': 'updatePinnedSavedDialogIds'; + ids: string[]; +}; + +export type ApiUpdateSavedDialogPinned = { + '@type': 'updateSavedDialogPinned'; + id: string; + isPinned: boolean; +}; + export type ApiUpdateChatFolder = { '@type': 'updateChatFolder'; id: number; @@ -312,7 +329,7 @@ export type ApiUpdateResetMessages = { export type ApiUpdateDraftMessage = { '@type': 'draftMessage'; chatId: string; - threadId?: number; + threadId?: ThreadId; draft?: ApiDraft; }; @@ -713,7 +730,7 @@ export type ApiUpdate = ( ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory | ApiUpdateSentStoryReaction | ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages | ApiUpdateStealthMode | ApiUpdateAttachMenuBots | ApiUpdateNewAuthorization | ApiUpdateGroupInvitePrivacyForbidden | - ApiUpdateViewForumAsMessages + ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/assets/font-icons/author-hidden.svg b/src/assets/font-icons/author-hidden.svg new file mode 100644 index 000000000..29611d53f --- /dev/null +++ b/src/assets/font-icons/author-hidden.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/my-notes.svg b/src/assets/font-icons/my-notes.svg new file mode 100644 index 000000000..18e760fb9 --- /dev/null +++ b/src/assets/font-icons/my-notes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 3a9a8ed36..9ad840c29 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -1,6 +1,6 @@ import type { MouseEvent as ReactMouseEvent } from 'react'; import type { FC, TeactNode } from '../../lib/teact/teact'; -import React, { memo, useRef } from '../../lib/teact/teact'; +import React, { memo, useMemo, useRef } from '../../lib/teact/teact'; import { getActions } from '../../global'; import type { @@ -16,6 +16,7 @@ import { getChatTitle, getPeerStoryHtmlId, getUserFullName, + isAnonymousForwardsChat, isChatWithRepliesBot, isDeletedUser, isUserId, @@ -33,6 +34,7 @@ import useMediaTransition from '../../hooks/useMediaTransition'; import OptimizedVideo from '../ui/OptimizedVideo'; import AvatarStoryCircle from './AvatarStoryCircle'; +import Icon from './Icon'; import './Avatar.scss'; @@ -51,6 +53,7 @@ type OwnProps = { photo?: ApiPhoto; text?: string; isSavedMessages?: boolean; + isSavedDialog?: boolean; withVideo?: boolean; withStory?: boolean; forPremiumPromo?: boolean; @@ -72,6 +75,7 @@ const Avatar: FC = ({ photo, text, isSavedMessages, + isSavedDialog, withVideo, withStory, forPremiumPromo, @@ -94,6 +98,7 @@ const Avatar: FC = ({ const chat = peer && isPeerChat ? peer as ApiChat : undefined; const isDeleted = user && isDeletedUser(user); const isReplies = peer && isChatWithRepliesBot(peer.id); + const isAnonymousForwards = peer && isAnonymousForwardsChat(peer.id); const isForum = chat?.isForum; let imageHash: string | undefined; let videoHash: string | undefined; @@ -112,6 +117,26 @@ const Avatar: FC = ({ } } + const specialIcon = useMemo(() => { + if (isSavedMessages) { + return isSavedDialog ? 'my-notes' : 'avatar-saved-messages'; + } + + if (isDeleted) { + return 'avatar-deleted-account'; + } + + if (isReplies) { + return 'reply-filled'; + } + + if (isAnonymousForwards) { + return 'author-hidden'; + } + + return undefined; + }, [isAnonymousForwards, isDeleted, isSavedDialog, isReplies, isSavedMessages]); + const imgBlobUrl = useMedia(imageHash, false, ApiMediaFormat.BlobUrl); const videoBlobUrl = useMedia(videoHash, !shouldLoadVideo, ApiMediaFormat.BlobUrl); const hasBlobUrl = Boolean(imgBlobUrl || videoBlobUrl); @@ -137,40 +162,13 @@ const Avatar: FC = ({ let content: TeactNode | undefined; const author = user ? getUserFullName(user) : (chat ? getChatTitle(lang, chat) : text); - if (isSavedMessages) { + if (specialIcon) { content = ( - - ); - } else if (isDeleted) { - content = ( - - ); - } else if (isReplies) { - content = ( - ); } else if (hasBlobUrl) { @@ -214,6 +212,7 @@ const Avatar: FC = ({ className, getPeerColorClass(peer), isSavedMessages && 'saved-messages', + isAnonymousForwards && 'anonymous-forwards', isDeleted && 'deleted-account', isReplies && 'replies-bot-account', isForum && 'forum', diff --git a/src/components/common/ChatExtra.tsx b/src/components/common/ChatExtra.tsx index cbfc61f56..4d34a7963 100644 --- a/src/components/common/ChatExtra.tsx +++ b/src/components/common/ChatExtra.tsx @@ -43,6 +43,7 @@ import Switcher from '../ui/Switcher'; type OwnProps = { chatOrUserId: string; + isSavedDialog?: boolean; forceShowSelf?: boolean; }; @@ -57,11 +58,13 @@ type StateProps = description?: string; chatInviteLink?: string; topicLink?: string; + hasSavedMessages?: boolean; }; const runDebounced = debounce((cb) => cb(), 500, false); const ChatExtra: FC = ({ + chatOrUserId, user, chat, forceShowSelf, @@ -72,6 +75,7 @@ const ChatExtra: FC = ({ description, chatInviteLink, topicLink, + hasSavedMessages, }) => { const { loadFullUser, @@ -79,6 +83,7 @@ const ChatExtra: FC = ({ updateChatMutedState, updateTopicMutedState, loadPeerStories, + openSavedDialog, } = getActions(); const { @@ -151,6 +156,10 @@ const ChatExtra: FC = ({ }); }); + const handleOpenSavedDialog = useLastCallback(() => { + openSavedDialog({ chatId: chatOrUserId }); + }); + if (!chat || chat.isRestricted || (isSelf && !forceShowSelf)) { return undefined; } @@ -264,12 +273,17 @@ const ChatExtra: FC = ({ /> )} + {hasSavedMessages && ( + + {lang('SavedMessagesTab')} + + )} ); }; export default memo(withGlobal( - (global, { chatOrUserId }): StateProps => { + (global, { chatOrUserId, isSavedDialog }): StateProps => { const { countryList: { phoneCodes: phoneCodeList } } = global; const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined; @@ -277,7 +291,7 @@ export default memo(withGlobal( const isForum = chat?.isForum; const isMuted = chat && selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)); const { threadId } = selectCurrentMessageList(global) || {}; - const topicId = isForum ? threadId : undefined; + const topicId = isForum ? Number(threadId) : undefined; const chatInviteLink = chat ? selectChatFullInfo(global, chat.id)?.inviteLink : undefined; let description = user ? selectUserFullInfo(global, user.id)?.bio : undefined; if (!description && chat) { @@ -291,6 +305,8 @@ export default memo(withGlobal( const topicLink = topicId ? selectTopicLink(global, chatOrUserId, topicId) : undefined; + const hasSavedMessages = !isSavedDialog && global.chats.listIds.saved?.includes(chatOrUserId); + return { phoneCodeList, chat, @@ -301,6 +317,7 @@ export default memo(withGlobal( chatInviteLink, description, topicLink, + hasSavedMessages, }; }, )(ChatExtra)); diff --git a/src/components/common/ChatOrUserPicker.tsx b/src/components/common/ChatOrUserPicker.tsx index d715aa6ad..a1d6b04c8 100644 --- a/src/components/common/ChatOrUserPicker.tsx +++ b/src/components/common/ChatOrUserPicker.tsx @@ -5,6 +5,7 @@ import React, { import { getActions } from '../../global'; import type { ApiChat, ApiTopic } from '../../api/types'; +import type { ThreadId } from '../../types'; import { CHAT_HEIGHT_PX } from '../../config'; import { getCanPostInChat, isUserId } from '../../global/helpers'; @@ -41,7 +42,7 @@ export type OwnProps = { className?: string; loadMore?: NoneToVoidFunction; onSearchChange: (search: string) => void; - onSelectChatOrUser: (chatOrUserId: string, threadId?: number) => void; + onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void; onClose: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction; }; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index f4e1468d7..625baded2 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -30,7 +30,9 @@ import type { ApiDraft, GlobalState, MessageList, MessageListType, TabState, } from '../../global/types'; -import type { IAnchorPosition, InlineBotSettings, ISettings } from '../../types'; +import type { + IAnchorPosition, InlineBotSettings, ISettings, ThreadId, +} from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { @@ -164,7 +166,7 @@ type ComposerType = 'messageList' | 'story'; type OwnProps = { type: ComposerType; chatId: string; - threadId: number; + threadId: ThreadId; storyId?: number; messageListType: MessageListType; dropAreaState?: string; diff --git a/src/components/common/FullNameTitle.tsx b/src/components/common/FullNameTitle.tsx index d5d5a3106..2f6ecb414 100644 --- a/src/components/common/FullNameTitle.tsx +++ b/src/components/common/FullNameTitle.tsx @@ -1,12 +1,14 @@ import type { FC } from '../../lib/teact/teact'; -import React, { memo } from '../../lib/teact/teact'; +import React, { memo, useMemo } from '../../lib/teact/teact'; import { getActions } from '../../global'; import type { ApiChat, ApiPeer, ApiUser } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { EMOJI_STATUS_LOOP_LIMIT } from '../../config'; -import { getChatTitle, getUserFullName, isUserId } from '../../global/helpers'; +import { + getChatTitle, getUserFullName, isAnonymousForwardsChat, isChatWithRepliesBot, isUserId, +} from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; import { copyTextToClipboard } from '../../util/clipboard'; import stopEvent from '../../util/stopEvent'; @@ -30,6 +32,7 @@ type OwnProps = { withEmojiStatus?: boolean; emojiStatusSize?: number; isSavedMessages?: boolean; + isSavedDialog?: boolean; noLoopLimit?: boolean; canCopyTitle?: boolean; onEmojiStatusClick?: NoneToVoidFunction; @@ -44,6 +47,7 @@ const FullNameTitle: FC = ({ withEmojiStatus, emojiStatusSize, isSavedMessages, + isSavedDialog, noLoopLimit, canCopyTitle, onEmojiStatusClick, @@ -65,10 +69,26 @@ const FullNameTitle: FC = ({ showNotification({ message: `${isUser ? 'User' : 'Chat'} name was copied` }); }); - if (isSavedMessages) { + const specialTitle = useMemo(() => { + if (isSavedMessages) { + return lang(isSavedDialog ? 'MyNotes' : 'SavedMessages'); + } + + if (isAnonymousForwardsChat(peer.id)) { + return lang('AnonymousForward'); + } + + if (isChatWithRepliesBot(peer.id)) { + return lang('RepliesTitle'); + } + + return undefined; + }, [isSavedDialog, isSavedMessages, lang, peer.id]); + + if (specialTitle) { return (
-

{lang('SavedMessages')}

+

{specialTitle}

); } diff --git a/src/components/common/GroupChatInfo.tsx b/src/components/common/GroupChatInfo.tsx index 4a24ec0a2..dbb053cd6 100644 --- a/src/components/common/GroupChatInfo.tsx +++ b/src/components/common/GroupChatInfo.tsx @@ -3,11 +3,11 @@ import React, { memo, useEffect, useMemo } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { - ApiChat, ApiThreadInfo, ApiTopic, ApiTypingStatus, + ApiChat, ApiThreadInfo, ApiTopic, ApiTypingStatus, ApiUser, } from '../../api/types'; import type { LangFn } from '../../hooks/useLang'; import type { IconName } from '../../types/icons'; -import { MediaViewerOrigin, type StoryViewerOrigin } from '../../types'; +import { MediaViewerOrigin, type StoryViewerOrigin, type ThreadId } from '../../types'; import { getChatTypeString, @@ -20,6 +20,7 @@ import { selectChatOnlineCount, selectThreadInfo, selectThreadMessagesCount, + selectUser, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { REM } from './helpers/mediaDimensions'; @@ -39,7 +40,7 @@ const TOPIC_ICON_SIZE = 2.5 * REM; type OwnProps = { chatId: string; - threadId?: number; + threadId?: ThreadId; className?: string; statusIcon?: IconName; typingStatus?: ApiTypingStatus; @@ -58,6 +59,7 @@ type OwnProps = { noStatusOrTyping?: boolean; withStory?: boolean; storyViewerOrigin?: StoryViewerOrigin; + isSavedDialog?: boolean; onClick?: VoidFunction; onEmojiStatusClick?: NoneToVoidFunction; }; @@ -70,6 +72,7 @@ type StateProps = onlineCount?: number; areMessagesLoaded: boolean; messagesCount?: number; + self?: ApiUser; }; const GroupChatInfo: FC = ({ @@ -97,6 +100,8 @@ const GroupChatInfo: FC = ({ storyViewerOrigin, noEmojiStatus, emojiStatusSize, + isSavedDialog, + self, onClick, onEmojiStatusClick, }) => { @@ -199,15 +204,28 @@ const GroupChatInfo: FC = ({ onClick={onClick} > {!noAvatar && !isTopic && ( - + <> + {isSavedDialog && self && ( + + )} + + )} {isTopic && ( = ({ peer={chat} emojiStatusSize={emojiStatusSize} withEmojiStatus={!noEmojiStatus} + isSavedDialog={isSavedDialog} onEmojiStatusClick={onEmojiStatusClick} /> )} @@ -258,6 +277,7 @@ export default memo(withGlobal( const areMessagesLoaded = Boolean(selectChatMessages(global, chatId)); const topic = threadId ? chat?.topics?.[threadId] : undefined; const messagesCount = topic && selectThreadMessagesCount(global, chatId, threadId!); + const self = selectUser(global, global.currentUserId!); return { chat, @@ -266,6 +286,7 @@ export default memo(withGlobal( topic, areMessagesLoaded, messagesCount, + self, }; }, )(GroupChatInfo)); diff --git a/src/components/common/Icon.tsx b/src/components/common/Icon.tsx index e147e8cd2..1575f4f44 100644 --- a/src/components/common/Icon.tsx +++ b/src/components/common/Icon.tsx @@ -1,3 +1,4 @@ +import type { AriaRole } from 'react'; import React from '../../lib/teact/teact'; import type { IconName } from '../../types/icons'; @@ -8,18 +9,24 @@ type OwnProps = { name: IconName; className?: string; style?: string; + role?: AriaRole; + ariaLabel?: string; }; const Icon = ({ name, className, style, + role, + ariaLabel, }: OwnProps) => { return ( ); }; diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index bb219275f..f24282dfc 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -44,6 +44,7 @@ type OwnProps = { noStatusOrTyping?: boolean; noRtl?: boolean; adminMember?: ApiChatMember; + isSavedDialog?: boolean; className?: string; onEmojiStatusClick?: NoneToVoidFunction; }; @@ -52,6 +53,7 @@ type StateProps = { user?: ApiUser; userStatus?: ApiUserStatus; + self?: ApiUser; isSavedMessages?: boolean; areMessagesLoaded: boolean; }; @@ -73,7 +75,9 @@ const PrivateChatInfo: FC = ({ noRtl, user, userStatus, + self, isSavedMessages, + isSavedDialog, areMessagesLoaded, adminMember, ripple, @@ -166,6 +170,7 @@ const PrivateChatInfo: FC = ({ withEmojiStatus={!noEmojiStatus} emojiStatusSize={emojiStatusSize} isSavedMessages={isSavedMessages} + isSavedDialog={isSavedDialog} onEmojiStatusClick={onEmojiStatusClick} /> {customTitle && {customTitle}} @@ -179,6 +184,7 @@ const PrivateChatInfo: FC = ({ withEmojiStatus={!noEmojiStatus} emojiStatusSize={emojiStatusSize} isSavedMessages={isSavedMessages} + isSavedDialog={isSavedDialog} onEmojiStatusClick={onEmojiStatusClick} /> ); @@ -186,11 +192,22 @@ const PrivateChatInfo: FC = ({ return (
+ {isSavedDialog && self && ( + + )} ( const user = selectUser(global, userId); const userStatus = selectUserStatus(global, userId); const isSavedMessages = !forceShowSelf && user && user.isSelf; + const self = isSavedMessages ? user : selectUser(global, global.currentUserId!); const areMessagesLoaded = Boolean(selectChatMessages(global, userId)); return { @@ -217,6 +235,7 @@ export default memo(withGlobal( userStatus, isSavedMessages, areMessagesLoaded, + self, }; }, )(PrivateChatInfo)); diff --git a/src/components/common/ProfileInfo.tsx b/src/components/common/ProfileInfo.tsx index bcc85e3a1..d7cffb4b9 100644 --- a/src/components/common/ProfileInfo.tsx +++ b/src/components/common/ProfileInfo.tsx @@ -9,7 +9,7 @@ import type { GlobalState } from '../../global/types'; import { MediaViewerOrigin } from '../../types'; import { - getUserStatus, isChatChannel, isUserId, isUserOnline, + getUserStatus, isAnonymousForwardsChat, isChatChannel, isUserId, isUserOnline, } from '../../global/helpers'; import { selectChat, @@ -53,7 +53,6 @@ type StateProps = user?: ApiUser; userStatus?: ApiUserStatus; chat?: ApiChat; - isSavedMessages?: boolean; mediaId?: number; avatarOwnerId?: string; topic?: ApiTopic; @@ -75,7 +74,6 @@ const ProfileInfo: FC = ({ user, userStatus, chat, - isSavedMessages, connectionState, mediaId, avatarOwnerId, @@ -107,8 +105,8 @@ const ProfileInfo: FC = ({ const slideAnimation = hasSlideAnimation ? (lang.isRtl ? 'slideRtl' : 'slide') : 'none'; const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); - const isFirst = isSavedMessages || photos.length <= 1 || currentPhotoIndex === 0; - const isLast = isSavedMessages || photos.length <= 1 || currentPhotoIndex === photos.length - 1; + const isFirst = photos.length <= 1 || currentPhotoIndex === 0; + const isLast = photos.length <= 1 || currentPhotoIndex === photos.length - 1; // Set the current avatar photo to the last selected photo in Media Viewer after it is closed useEffect(() => { @@ -227,7 +225,7 @@ const ProfileInfo: FC = ({ } function renderPhotoTabs() { - if (isSavedMessages || !photos || photos.length <= 1) { + if (!photos || photos.length <= 1) { return undefined; } @@ -241,7 +239,7 @@ const ProfileInfo: FC = ({ } function renderPhoto(isActive?: boolean) { - const photo = !isSavedMessages && photos.length > 0 + const photo = photos.length > 0 ? photos[currentPhotoIndex] : undefined; const profilePhoto = photo || userPersonalPhoto || userProfilePhoto || chatProfilePhoto || userFallbackPhoto; @@ -252,7 +250,6 @@ const ProfileInfo: FC = ({ user={user} chat={chat} photo={profilePhoto} - isSavedMessages={isSavedMessages} canPlayVideo={Boolean(isActive && canPlayVideo)} onClick={handleProfilePhotoClick} /> @@ -260,6 +257,11 @@ const ProfileInfo: FC = ({ } function renderStatus() { + const peerId = (chatId || userId)!; + + const isAnonymousForwards = isAnonymousForwardsChat(peerId); + if (isAnonymousForwards) return undefined; + if (user) { return (
@@ -348,26 +350,24 @@ const ProfileInfo: FC = ({ peer={(user || chat)!} withEmojiStatus emojiStatusSize={EMOJI_STATUS_SIZE} - isSavedMessages={isSavedMessages} onEmojiStatusClick={handleStatusClick} noLoopLimit canCopyTitle /> )} - {!isSavedMessages && renderStatus()} + {renderStatus()}
); }; export default memo(withGlobal( - (global, { userId, forceShowSelf }): StateProps => { + (global, { userId }): StateProps => { const { connectionState } = global; const user = selectUser(global, userId); const isPrivate = isUserId(userId); const userStatus = selectUserStatus(global, userId); const chat = selectChat(global, userId); - const isSavedMessages = !forceShowSelf && user && user.isSelf; const { mediaId, avatarOwnerId } = selectTabState(global).mediaViewer; const isForum = chat?.isForum; const { threadId: currentTopicId } = selectCurrentMessageList(global) || {}; @@ -387,7 +387,6 @@ export default memo(withGlobal( userProfilePhoto: userFullInfo?.profilePhoto, userFallbackPhoto: userFullInfo?.fallbackPhoto, chatProfilePhoto: chatFullInfo?.profilePhoto, - isSavedMessages, mediaId, avatarOwnerId, emojiStatusSticker, diff --git a/src/components/common/ProfilePhoto.tsx b/src/components/common/ProfilePhoto.tsx index f2f236a80..9fdcbc35a 100644 --- a/src/components/common/ProfilePhoto.tsx +++ b/src/components/common/ProfilePhoto.tsx @@ -1,5 +1,7 @@ import type { FC, TeactNode } from '../../lib/teact/teact'; -import React, { memo, useEffect, useRef } from '../../lib/teact/teact'; +import React, { + memo, useEffect, useMemo, useRef, +} from '../../lib/teact/teact'; import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types'; @@ -8,6 +10,7 @@ import { getChatTitle, getUserFullName, getVideoAvatarMediaHash, + isAnonymousForwardsChat, isChatWithRepliesBot, isDeletedUser, isUserId, @@ -27,6 +30,7 @@ import useMediaTransition from '../../hooks/useMediaTransition'; import OptimizedVideo from '../ui/OptimizedVideo'; import Spinner from '../ui/Spinner'; +import Icon from './Icon'; import './ProfilePhoto.scss'; @@ -34,6 +38,7 @@ type OwnProps = { chat?: ApiChat; user?: ApiUser; isSavedMessages?: boolean; + isSavedDialog?: boolean; photo?: ApiPhoto; canPlayVideo: boolean; onClick: NoneToVoidFunction; @@ -44,6 +49,7 @@ const ProfilePhoto: FC = ({ user, photo, isSavedMessages, + isSavedDialog, canPlayVideo, onClick, }) => { @@ -55,8 +61,9 @@ const ProfilePhoto: FC = ({ const isDeleted = user && isDeletedUser(user); const isRepliesChat = chat && isChatWithRepliesBot(chat.id); + const isAnonymousForwards = chat && isAnonymousForwardsChat(chat.id); const peer = user || chat; - const canHaveMedia = peer && !isSavedMessages && !isDeleted && !isRepliesChat; + const canHaveMedia = peer && !isSavedMessages && !isDeleted && !isRepliesChat && !isAnonymousForwards; const { isVideo } = photo || {}; const avatarHash = canHaveMedia && getChatAvatarHash(peer, 'normal'); @@ -84,14 +91,30 @@ const ProfilePhoto: FC = ({ } }, [canPlayVideo]); + const specialIcon = useMemo(() => { + if (isSavedMessages) { + return isSavedDialog ? 'my-notes' : 'avatar-saved-messages'; + } + + if (isDeleted) { + return 'avatar-deleted-account'; + } + + if (isRepliesChat) { + return 'reply-filled'; + } + + if (isAnonymousForwards) { + return 'author-hidden'; + } + + return undefined; + }, [isAnonymousForwards, isDeleted, isSavedDialog, isRepliesChat, isSavedMessages]); + let content: TeactNode | undefined; - if (isSavedMessages) { - content = ; - } else if (isDeleted) { - content = ; - } else if (isRepliesChat) { - content = ; + if (specialIcon) { + content = ; } else if (hasMedia) { content = ( <> @@ -141,6 +164,7 @@ const ProfilePhoto: FC = ({ 'ProfilePhoto', getPeerColorClass(peer), isSavedMessages && 'saved-messages', + isAnonymousForwards && 'anonymous-forwards', isDeleted && 'deleted-account', isRepliesChat && 'replies-bot-account', (!isSavedMessages && !hasMedia) && 'no-photo', diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index 84f742c37..38fd95a97 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -3,6 +3,7 @@ import React, { memo, useMemo, useState } from '../../lib/teact/teact'; import { getGlobal, withGlobal } from '../../global'; import type { ApiChat, ApiChatType } from '../../api/types'; +import type { ThreadId } from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { API_CHAT_TYPES } from '../../config'; @@ -11,10 +12,10 @@ import { filterUsersByName, getCanPostInChat, isDeletedUser, - sortChatIds, } from '../../global/helpers'; import { filterChatIdsByType } from '../../global/selectors'; import { unique } from '../../util/iteratees'; +import sortChatIds from './helpers/sortChatIds'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useLang from '../../hooks/useLang'; @@ -27,7 +28,7 @@ export type OwnProps = { className?: string; filter?: ApiChatType[]; loadMore?: NoneToVoidFunction; - onSelectRecipient: (peerId: string, threadId?: number) => void; + onSelectRecipient: (peerId: string, threadId?: ThreadId) => void; onClose: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction; }; @@ -85,7 +86,7 @@ const RecipientPicker: FC = ({ const sorted = sortChatIds(unique([ ...filterChatsByName(lang, chatIds, chatsById, search, currentUserId), ...(contactIds && filter.includes('users') ? filterUsersByName(contactIds, usersById, search) : []), - ]), chatsById, undefined, priorityIds); + ]), undefined, priorityIds); return filterChatIdsByType(global, sorted, filter); }, [pinnedIds, currentUserId, activeListIds, search, archivedListIds, lang, chatsById, contactIds, filter, isOpen]); diff --git a/src/components/common/helpers/sortChatIds.ts b/src/components/common/helpers/sortChatIds.ts new file mode 100644 index 000000000..cd17bb8bd --- /dev/null +++ b/src/components/common/helpers/sortChatIds.ts @@ -0,0 +1,39 @@ +import { getGlobal } from '../../../global'; + +import { selectChat, selectChatLastMessage } from '../../../global/selectors'; +import { orderBy } from '../../../util/iteratees'; + +const VERIFIED_PRIORITY_BASE = 3e9; +const PINNED_PRIORITY_BASE = 3e8; + +export default function sortChatIds( + chatIds: string[], + shouldPrioritizeVerified = false, + priorityIds?: string[], +) { + // Avoid calling sort on every global change + const global = getGlobal(); + return orderBy(chatIds, (id) => { + const chat = selectChat(global, id); + if (!chat) { + return 0; + } + + let priority = 0; + + const lastMessage = selectChatLastMessage(global, id); + if (lastMessage) { + priority += lastMessage.date; + } + + if (shouldPrioritizeVerified && chat.isVerified) { + priority += VERIFIED_PRIORITY_BASE; // ~100 years in seconds + } + + if (priorityIds && priorityIds.includes(id)) { + priority = Date.now() + PINNED_PRIORITY_BASE + (priorityIds.length - priorityIds.indexOf(id)); + } + + return priority; + }, 'desc'); +} diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 51bf9674b..859c66481 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useEffect } from '../../../lib/teact/teact'; +import React, { memo, useEffect, useMemo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { @@ -29,6 +29,8 @@ import { getMessageReplyInfo } from '../../../global/helpers/replies'; import { selectCanAnimateInterface, selectChat, + selectChatLastMessage, + selectChatLastMessageId, selectChatMessage, selectCurrentMessageList, selectDraft, @@ -37,6 +39,7 @@ import { selectNotifyExceptions, selectNotifySettings, selectOutgoingStatus, + selectPeer, selectTabState, selectThreadParam, selectTopicFromMessage, @@ -49,6 +52,7 @@ import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/windowEnvironment'; import useAppLayout from '../../../hooks/useAppLayout'; import useChatContextActions from '../../../hooks/useChatContextActions'; +import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useFlag from '../../../hooks/useFlag'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../hooks/useLastCallback'; @@ -76,6 +80,7 @@ type OwnProps = { animationType: ChatAnimationTypes; isPinned?: boolean; offsetTop: number; + isSavedDialog?: boolean; observeIntersection?: ObserveFn; onDragEnter?: (chatId: string) => void; }; @@ -99,6 +104,9 @@ type StateProps = { lastMessageTopic?: ApiTopic; typingStatus?: ApiTypingStatus; withInterfaceAnimations?: boolean; + lastMessageId?: number; + lastMessage?: ApiMessage; + currentUserId: string; }; const Chat: FC = ({ @@ -127,10 +135,16 @@ const Chat: FC = ({ canChangeFolder, lastMessageTopic, typingStatus, + lastMessageId, + lastMessage, + isSavedDialog, + currentUserId, onDragEnter, }) => { const { openChat, + openSavedDialog, + toggleChatInfo, focusLastMessage, loadTopics, openForumPanel, @@ -147,7 +161,9 @@ const Chat: FC = ({ const [shouldRenderChatFolderModal, markRenderChatFolderModal, unmarkRenderChatFolderModal] = useFlag(); const [shouldRenderReportModal, markRenderReportModal, unmarkRenderReportModal] = useFlag(); - const { lastMessage, isForum, isForumAsMessages } = chat || {}; + const { isForum, isForumAsMessages } = chat || {}; + + useEnsureMessage(isSavedDialog ? currentUserId : chatId, lastMessageId, lastMessage); const { renderSubtitle, ref } = useChatListEntry({ chat, @@ -164,6 +180,7 @@ const Chat: FC = ({ animationType, withInterfaceAnimations, orderDiff, + isSavedDialog, }); const getIsForumPanelClosed = useSelectorSignal(selectIsForumPanelClosed); @@ -171,6 +188,15 @@ const Chat: FC = ({ const handleClick = useLastCallback(() => { const noForumTopicPanel = isMobile && isForumAsMessages; + if (isSavedDialog) { + openSavedDialog({ chatId, noForumTopicPanel: true }, { forceOnHeavyAnimation: true }); + + if (isMobile) { + toggleChatInfo({ force: false }); + } + return; + } + if (isForum) { if (isForumPanelOpen) { closeForumPanel(undefined, { forceOnHeavyAnimation: true }); @@ -228,6 +254,8 @@ const Chat: FC = ({ isPinned, isMuted, canChangeFolder, + isSavedDialog, + currentUserId, }); const isIntersecting = useIsIntersecting(ref, chat ? observeIntersection : undefined); @@ -242,6 +270,16 @@ const Chat: FC = ({ const isOnline = user && userStatus && isUserOnline(user, userStatus); const { hasShownClass: isAvatarOnlineShown } = useShowTransition(isOnline); + const href = useMemo(() => { + if (!IS_OPEN_IN_NEW_TAB_SUPPORTED) return undefined; + + if (isSavedDialog) { + return `#${createLocationHash(currentUserId, 'thread', chatId)}`; + } + + return `#${createLocationHash(chatId, 'thread', MAIN_THREAD_ID)}`; + }, [chatId, currentUserId, isSavedDialog]); + if (!chat) { return undefined; } @@ -260,7 +298,7 @@ const Chat: FC = ({ = ({ = ({ peer={peer} withEmojiStatus isSavedMessages={chatId === user?.id && user?.isSelf} + isSavedDialog={isSavedDialog} observeIntersection={observeIntersection} /> - {isMuted && } + {isMuted && !isSavedDialog && }
- {chat.lastMessage && ( + {lastMessage && ( )}
{renderSubtitle()} - +
{shouldRenderDeleteModal && ( @@ -346,29 +386,34 @@ const Chat: FC = ({ }; export default memo(withGlobal( - (global, { chatId }): StateProps => { + (global, { chatId, isSavedDialog }): StateProps => { const chat = selectChat(global, chatId); if (!chat) { - return {}; + return { + currentUserId: global.currentUserId!, + }; } - const { lastMessage } = chat; - const { senderId, isOutgoing } = lastMessage || {}; + const lastMessageId = selectChatLastMessageId(global, chatId, isSavedDialog ? 'saved' : 'all'); + const lastMessage = selectChatLastMessage(global, chatId, isSavedDialog ? 'saved' : 'all'); + const { senderId, isOutgoing, forwardInfo } = lastMessage || {}; + const actualSenderId = isSavedDialog ? forwardInfo?.fromId : senderId; const replyToMessageId = lastMessage && getMessageReplyInfo(lastMessage)?.replyToMsgId; - const lastMessageSender = senderId - ? (selectUser(global, senderId) || selectChat(global, senderId)) : undefined; + const lastMessageSender = actualSenderId ? selectPeer(global, actualSenderId) : undefined; const lastMessageAction = lastMessage ? getMessageAction(lastMessage) : undefined; const actionTargetMessage = lastMessageAction && replyToMessageId ? selectChatMessage(global, chat.id, replyToMessageId) : undefined; const { targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId } = lastMessageAction || {}; const privateChatUserId = getPrivateChatUserId(chat); + const { chatId: currentChatId, threadId: currentThreadId, type: messageListType, } = selectCurrentMessageList(global) || {}; - const isSelected = chatId === currentChatId && currentThreadId === MAIN_THREAD_ID; + const isSelected = chatId === currentChatId && (isSavedDialog + ? chatId === currentThreadId : currentThreadId === MAIN_THREAD_ID); const isSelectedForum = (chat.isForum && chatId === currentChatId) || chatId === selectTabState(global).forumPanelChatId; @@ -399,6 +444,9 @@ export default memo(withGlobal( lastMessageTopic, typingStatus, withInterfaceAnimations: selectCanAnimateInterface(global), + lastMessage, + lastMessageId, + currentUserId: global.currentUserId!, }; }, )(Chat)); diff --git a/src/components/left/main/ChatBadge.tsx b/src/components/left/main/ChatBadge.tsx index 6106476bf..e39b9c291 100644 --- a/src/components/left/main/ChatBadge.tsx +++ b/src/components/left/main/ChatBadge.tsx @@ -21,12 +21,13 @@ type OwnProps = { wasTopicOpened?: boolean; isPinned?: boolean; isMuted?: boolean; + isSavedDialog?: boolean; shouldShowOnlyMostImportant?: boolean; forceHidden?: boolean | Signal; }; const ChatBadge: FC = ({ - topic, chat, isPinned, isMuted, shouldShowOnlyMostImportant, wasTopicOpened, forceHidden, + topic, chat, isPinned, isMuted, shouldShowOnlyMostImportant, wasTopicOpened, forceHidden, isSavedDialog, }) => { const { unreadMentionsCount = 0, unreadReactionsCount = 0, @@ -64,7 +65,7 @@ const ChatBadge: FC = ({ || isTopicUnopened, ); - const isUnread = Boolean(unreadCount || hasUnreadMark); + const isUnread = Boolean((unreadCount || hasUnreadMark) && !isSavedDialog); const className = buildClassName( 'ChatBadge', shouldBeMuted && 'muted', @@ -95,16 +96,21 @@ const ChatBadge: FC = ({ ) : undefined; - const pinnedElement = isPinned && !unreadCountElement && !unreadMentionsElement && !unreadReactionsElement && ( + const pinnedElement = isPinned && (
); + const visiblePinnedElement = !unreadCountElement && !unreadMentionsElement && !unreadReactionsElement + && pinnedElement; + const elements = [ - unopenedTopicElement, unreadReactionsElement, unreadMentionsElement, unreadCountElement, pinnedElement, + unopenedTopicElement, unreadReactionsElement, unreadMentionsElement, unreadCountElement, visiblePinnedElement, ].filter(Boolean); + if (isSavedDialog) return pinnedElement; + if (elements.length === 0) return undefined; if (elements.length === 1) return elements[0]; diff --git a/src/components/left/main/ChatList.tsx b/src/components/left/main/ChatList.tsx index c599c9457..8a35018e9 100644 --- a/src/components/left/main/ChatList.tsx +++ b/src/components/left/main/ChatList.tsx @@ -17,6 +17,7 @@ import { CHAT_HEIGHT_PX, CHAT_LIST_SLICE, FRESH_AUTH_PERIOD, + SAVED_FOLDER_ID, } from '../../../config'; import buildClassName from '../../../util/buildClassName'; import { getOrderKey, getPinnedChatsCount } from '../../../util/folderManager'; @@ -41,16 +42,17 @@ import EmptyFolder from './EmptyFolder'; import UnconfirmedSession from './UnconfirmedSession'; type OwnProps = { - folderType: 'all' | 'archived' | 'folder'; + className?: string; + folderType: 'all' | 'archived' | 'saved' | 'folder'; folderId?: number; isActive: boolean; canDisplayArchive?: boolean; - archiveSettings: GlobalState['archiveSettings']; + archiveSettings?: GlobalState['archiveSettings']; isForumPanelOpen?: boolean; sessions?: Record; - foldersDispatch: FolderEditDispatch; - onSettingsScreenSelect: (screen: SettingsScreens) => void; - onLeftColumnContentChange: (content: LeftColumnContent) => void; + foldersDispatch?: FolderEditDispatch; + onSettingsScreenSelect?: (screen: SettingsScreens) => void; + onLeftColumnContentChange?: (content: LeftColumnContent) => void; }; const INTERSECTION_THROTTLE = 200; @@ -58,6 +60,7 @@ const DRAG_ENTER_DEBOUNCE = 500; const RESERVED_HOTKEYS = new Set(['9', '0']); const ChatList: FC = ({ + className, folderType, folderId, isActive, @@ -82,18 +85,19 @@ const ChatList: FC = ({ const isArchived = folderType === 'archived'; const isAllFolder = folderType === 'all'; + const isSaved = folderType === 'saved'; const resolvedFolderId = ( - isAllFolder ? ALL_FOLDER_ID : isArchived ? ARCHIVED_FOLDER_ID : folderId! + isAllFolder ? ALL_FOLDER_ID : isArchived ? ARCHIVED_FOLDER_ID : isSaved ? SAVED_FOLDER_ID : folderId! ); - const shouldDisplayArchive = isAllFolder && canDisplayArchive; + const shouldDisplayArchive = isAllFolder && canDisplayArchive && archiveSettings; const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId); usePeerStoriesPolling(orderedIds); const chatsHeight = (orderedIds?.length || 0) * CHAT_HEIGHT_PX; const archiveHeight = shouldDisplayArchive - ? archiveSettings.isMinimized ? ARCHIVE_MINIMIZED_HEIGHT : CHAT_HEIGHT_PX : 0; + ? archiveSettings?.isMinimized ? ARCHIVE_MINIMIZED_HEIGHT : CHAT_HEIGHT_PX : 0; const { orderDiffById, getAnimationType } = useOrderDiff(orderedIds); @@ -125,7 +129,7 @@ const ChatList: FC = ({ // Support + to navigate between chats useEffect(() => { - if (!isActive || !orderedIds || !IS_APP) { + if (!isActive || isSaved || !orderedIds || !IS_APP) { return undefined; } @@ -134,13 +138,13 @@ const ChatList: FC = ({ const [, digit] = e.code.match(/Digit(\d)/) || []; if (!digit || RESERVED_HOTKEYS.has(digit)) return; - const isArchiveInList = shouldDisplayArchive && !archiveSettings.isMinimized; + const isArchiveInList = shouldDisplayArchive && archiveSettings && !archiveSettings.isMinimized; const shift = isArchiveInList ? -1 : 0; const position = Number(digit) + shift - 1; if (isArchiveInList && position === -1) { - onLeftColumnContentChange(LeftColumnContent.Archived); + onLeftColumnContentChange?.(LeftColumnContent.Archived); return; } @@ -155,7 +159,10 @@ const ChatList: FC = ({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [archiveSettings, isActive, onLeftColumnContentChange, openChat, openNextChat, orderedIds, shouldDisplayArchive]); + }, [ + archiveSettings, isSaved, isActive, onLeftColumnContentChange, openChat, openNextChat, orderedIds, + shouldDisplayArchive, + ]); const { observe } = useIntersectionObserver({ rootRef: containerRef, @@ -163,7 +170,7 @@ const ChatList: FC = ({ }); const handleArchivedClick = useLastCallback(() => { - onLeftColumnContentChange(LeftColumnContent.Archived); + onLeftColumnContentChange!(LeftColumnContent.Archived); closeForumPanel(); }); @@ -199,7 +206,7 @@ const ChatList: FC = ({ toggleStoryRibbon({ isShown: false, isArchived }); }); - const renderedOverflowTrigger = useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon); + const renderedOverflowTrigger = useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon, isSaved); function renderChats() { const viewportOffset = orderedIds!.indexOf(viewportIds![0]); @@ -213,10 +220,11 @@ const ChatList: FC = ({ return ( = ({ return ( = ({ )} {viewportIds?.length ? ( renderChats() - ) : viewportIds && !viewportIds.length ? ( + ) : viewportIds && !viewportIds.length && !isSaved ? ( ( ) ) : ( diff --git a/src/components/left/main/EmptyFolder.tsx b/src/components/left/main/EmptyFolder.tsx index 6f4bcc8b2..8b20a22b1 100644 --- a/src/components/left/main/EmptyFolder.tsx +++ b/src/components/left/main/EmptyFolder.tsx @@ -18,7 +18,7 @@ import styles from './EmptyFolder.module.scss'; type OwnProps = { folderId?: number; - folderType: 'all' | 'archived' | 'folder'; + folderType: 'all' | 'archived' | 'saved' | 'folder'; foldersDispatch: FolderEditDispatch; onSettingsScreenSelect: (screen: SettingsScreens) => void; }; diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/ForumPanel.tsx index 441f8f8c1..cfb132d54 100644 --- a/src/components/left/main/ForumPanel.tsx +++ b/src/components/left/main/ForumPanel.tsx @@ -288,7 +288,7 @@ export default memo(withGlobal( return { chat, - currentTopicId: chatId === currentChatId ? currentThreadId : undefined, + currentTopicId: chatId === currentChatId ? Number(currentThreadId) : undefined, withInterfaceAnimations: selectCanAnimateInterface(global), }; }, diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index 03e9be663..d7cbe91f3 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -58,6 +58,7 @@ export default function useChatListEntry({ orderDiff, withInterfaceAnimations, isTopic, + isSavedDialog, }: { chat?: ApiChat; lastMessage?: ApiMessage; @@ -71,6 +72,7 @@ export default function useChatListEntry({ actionTargetChatId?: string; observeIntersection?: ObserveFn; isTopic?: boolean; + isSavedDialog?: boolean; animationType: ChatAnimationTypes; orderDiff: number; @@ -102,14 +104,15 @@ export default function useChatListEntry({ }, [actionTargetUserIds]); const renderLastMessageOrTyping = useCallback(() => { - if (typingStatus && lastMessage && typingStatus.timestamp > lastMessage.date * 1000) { + if (!isSavedDialog && typingStatus && lastMessage && typingStatus.timestamp > lastMessage.date * 1000) { return ; } const isDraftReplyToTopic = draft && draft.replyInfo?.replyToMsgId === lastMessageTopic?.id; const isEmptyLocalReply = draft?.replyInfo && !draft.text && draft.isLocal; - const canDisplayDraft = !chat?.isForum && draft && !isEmptyLocalReply && (!isTopic || !isDraftReplyToTopic); + const canDisplayDraft = !chat?.isForum && !isSavedDialog && draft && !isEmptyLocalReply + && (!isTopic || !isDraftReplyToTopic); if (canDisplayDraft) { return ( @@ -169,7 +172,7 @@ export default function useChatListEntry({ : )} - {lastMessage.forwardInfo && ()} + {!isSavedDialog && lastMessage.forwardInfo && ()} {lastMessage.replyInfo?.type === 'story' && ()} {renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}

@@ -177,7 +180,7 @@ export default function useChatListEntry({ }, [ actionTargetChatId, actionTargetMessage, actionTargetUsers, chat, chatId, draft, isAction, isRoundVideo, isTopic, lang, lastMessage, lastMessageSender, lastMessageTopic, mediaBlobUrl, mediaThumbnail, - observeIntersection, typingStatus, + observeIntersection, typingStatus, isSavedDialog, ]); function renderSubtitle() { diff --git a/src/components/left/newChat/NewChatStep1.tsx b/src/components/left/newChat/NewChatStep1.tsx index ca8383d07..89a953f5f 100644 --- a/src/components/left/newChat/NewChatStep1.tsx +++ b/src/components/left/newChat/NewChatStep1.tsx @@ -2,11 +2,10 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../../global'; -import type { ApiChat } from '../../../api/types'; - -import { filterUsersByName, isUserBot, sortChatIds } from '../../../global/helpers'; +import { filterUsersByName, isUserBot } from '../../../global/helpers'; import { selectTabState } from '../../../global/selectors'; import { unique } from '../../../util/iteratees'; +import sortChatIds from '../../common/helpers/sortChatIds'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useLang from '../../../hooks/useLang'; @@ -25,7 +24,6 @@ export type OwnProps = { }; type StateProps = { - chatsById: Record; localContactIds?: string[]; searchQuery?: string; isSearching?: boolean; @@ -40,7 +38,6 @@ const NewChatStep1: FC = ({ onSelectedMemberIdsChange, onNextStep, onReset, - chatsById, localContactIds, searchQuery, isSearching, @@ -80,11 +77,10 @@ const NewChatStep1: FC = ({ return !user.isSelf && (user.canBeInvitedToGroup || !isUserBot(user)); }), - chatsById, false, selectedMemberIds, ); - }, [localContactIds, chatsById, searchQuery, localUserIds, globalUserIds, selectedMemberIds]); + }, [localContactIds, searchQuery, localUserIds, globalUserIds, selectedMemberIds]); const handleNextStep = useCallback(() => { setGlobalSearchQuery({ query: '' }); @@ -133,7 +129,6 @@ const NewChatStep1: FC = ({ export default memo(withGlobal( (global): StateProps => { const { userIds: localContactIds } = global.contactList || {}; - const { byId: chatsById } = global.chats; const { query: searchQuery, @@ -145,7 +140,6 @@ export default memo(withGlobal( const { userIds: localUserIds } = localResults || {}; return { - chatsById, localContactIds, searchQuery, isSearching: fetchingStatus?.chats, diff --git a/src/components/left/search/ChatResults.tsx b/src/components/left/search/ChatResults.tsx index 04e229a7d..1b2ae4a3b 100644 --- a/src/components/left/search/ChatResults.tsx +++ b/src/components/left/search/ChatResults.tsx @@ -11,7 +11,6 @@ import { ALL_FOLDER_ID } from '../../../config'; import { filterChatsByName, filterUsersByName, - sortChatIds, } from '../../../global/helpers'; import { selectTabState } from '../../../global/selectors'; import { getOrderedIds } from '../../../util/folderManager'; @@ -19,6 +18,7 @@ import { unique } from '../../../util/iteratees'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { throttle } from '../../../util/schedulers'; import { renderMessageSummary } from '../../common/helpers/renderMessageText'; +import sortChatIds from '../../common/helpers/sortChatIds'; import useAppLayout from '../../../hooks/useAppLayout'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; @@ -147,8 +147,8 @@ const ChatResults: FC = ({ ].filter((accountPeerId) => !localPeerIds.includes(accountPeerId))); return [ - ...sortChatIds(localPeerIds, chatsById, undefined, currentUserId ? [currentUserId] : undefined), - ...sortChatIds(accountPeerIds, chatsById), + ...sortChatIds(localPeerIds, undefined, currentUserId ? [currentUserId] : undefined), + ...sortChatIds(accountPeerIds), ]; }, [searchQuery, currentUserId, contactIds, lang, accountChatIds, accountUserIds, chatsById]); @@ -161,10 +161,9 @@ const ChatResults: FC = ({ return sortChatIds( unique([...globalChatIds, ...globalUserIds]), - chatsById, true, ); - }, [chatsById, globalChatIds, globalUserIds, searchQuery]); + }, [globalChatIds, globalUserIds, searchQuery]); const foundMessages = useMemo(() => { if ((!searchQuery && !searchDate) || !foundIds || foundIds.length === 0) { diff --git a/src/components/main/DraftRecipientPicker.tsx b/src/components/main/DraftRecipientPicker.tsx index 6579a2c8d..e059d4085 100644 --- a/src/components/main/DraftRecipientPicker.tsx +++ b/src/components/main/DraftRecipientPicker.tsx @@ -5,6 +5,7 @@ import React, { import { getActions } from '../../global'; import type { TabState } from '../../global/types'; +import type { ThreadId } from '../../types'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; @@ -33,7 +34,7 @@ const DraftRecipientPicker: FC = ({ } }, [isOpen, markIsShown]); - const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => { + const handleSelectRecipient = useCallback((recipientId: string, threadId?: ThreadId) => { openChatWithDraft({ chatId: recipientId, threadId, diff --git a/src/components/main/ForwardRecipientPicker.tsx b/src/components/main/ForwardRecipientPicker.tsx index 0e7598631..8ba2200dc 100644 --- a/src/components/main/ForwardRecipientPicker.tsx +++ b/src/components/main/ForwardRecipientPicker.tsx @@ -4,6 +4,8 @@ import React, { } from '../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../global'; +import type { ThreadId } from '../../types'; + import { getChatTitle, getUserFirstOrLastName, isUserId } from '../../global/helpers'; import { selectChat, selectTabState, selectUser } from '../../global/selectors'; @@ -47,7 +49,7 @@ const ForwardRecipientPicker: FC = ({ } }, [isOpen, markIsShown]); - const handleSelectRecipient = useCallback((recipientId: string, threadId?: number) => { + const handleSelectRecipient = useCallback((recipientId: string, threadId?: ThreadId) => { const isSelf = recipientId === currentUserId; if (isStory) { forwardStory({ toChatId: recipientId }); @@ -82,7 +84,7 @@ const ForwardRecipientPicker: FC = ({ forwardToSavedMessages(); showNotification({ message }); } else { - setForwardChatOrTopic({ chatId: recipientId, topicId: threadId }); + setForwardChatOrTopic({ chatId: recipientId, topicId: Number(threadId) }); } }, [currentUserId, isManyMessages, isStory, lang]); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 9fb1ab657..2a0169966 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -227,6 +227,7 @@ const Main: FC = ({ isSynced, inviteViaLinkModal, oneTimeMediaModal, + currentUserId, }) => { const { initMain, @@ -420,7 +421,7 @@ const Main: FC = ({ }, []); useEffect(() => { - const parsedLocationHash = parseLocationHash(); + const parsedLocationHash = parseLocationHash(currentUserId); if (!parsedLocationHash) return; openThread({ @@ -428,7 +429,7 @@ const Main: FC = ({ threadId: parsedLocationHash.threadId, type: parsedLocationHash.type, }); - }, []); + }, [currentUserId]); // Restore Transition slide class after async rendering useLayoutEffect(() => { diff --git a/src/components/main/premium/PremiumFeatureModal.tsx b/src/components/main/premium/PremiumFeatureModal.tsx index 34b75b14a..b1d43b3ca 100644 --- a/src/components/main/premium/PremiumFeatureModal.tsx +++ b/src/components/main/premium/PremiumFeatureModal.tsx @@ -86,7 +86,9 @@ const PREMIUM_BOTTOM_VIDEOS: string[] = [ 'translations', ]; -type ApiLimitTypeWithoutUpload = Exclude; +type ApiLimitTypeWithoutUpload = Exclude; const LIMITS_ORDER: ApiLimitTypeWithoutUpload[] = [ 'channels', diff --git a/src/components/main/premium/common/PremiumLimitReachedModal.tsx b/src/components/main/premium/common/PremiumLimitReachedModal.tsx index 3c705a9a3..f0d967143 100644 --- a/src/components/main/premium/common/PremiumLimitReachedModal.tsx +++ b/src/components/main/premium/common/PremiumLimitReachedModal.tsx @@ -31,6 +31,7 @@ const LIMIT_DESCRIPTION: Record = { channels: 'LimitReachedCommunities', chatlistInvites: 'LimitReachedFolderLinks', chatlistJoined: 'LimitReachedSharedFolders', + savedDialogsPinned: 'LimitReachedPinSavedDialogs', }; const LIMIT_DESCRIPTION_BLOCKED: Record = { @@ -42,6 +43,7 @@ const LIMIT_DESCRIPTION_BLOCKED: Record = { channels: 'LimitReachedCommunitiesLocked', chatlistInvites: 'LimitReachedFolderLinksLocked', chatlistJoined: 'LimitReachedSharedFoldersLocked', + savedDialogsPinned: 'LimitReachedPinSavedDialogsLocked', }; const LIMIT_DESCRIPTION_PREMIUM: Record = { @@ -53,6 +55,7 @@ const LIMIT_DESCRIPTION_PREMIUM: Record = { channels: 'LimitReachedCommunitiesPremium', chatlistInvites: 'LimitReachedFolderLinksPremium', chatlistJoined: 'LimitReachedSharedFoldersPremium', + savedDialogsPinned: 'LimitReachedPinSavedDialogsPremium', }; const LIMIT_ICON: Record = { @@ -64,6 +67,7 @@ const LIMIT_ICON: Record = { channels: 'chats-badge', chatlistInvites: 'link-badge', chatlistJoined: 'folder-badge', + savedDialogsPinned: 'pin-badge', }; const LIMIT_VALUE_FORMATTER: Partial string>> = { diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 8902c7f09..5b3b2884e 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -7,7 +7,7 @@ import { getActions, withGlobal } from '../../global'; import type { ApiMessage, ApiPeer, ApiPhoto, ApiUser, } from '../../api/types'; -import { MediaViewerOrigin } from '../../types'; +import { MediaViewerOrigin, type ThreadId } from '../../types'; import { ANIMATION_END_DELAY } from '../../config'; import { getChatMediaMessageIds, isChatAdmin, isUserId } from '../../global/helpers'; @@ -57,7 +57,7 @@ import './MediaViewer.scss'; type StateProps = { chatId?: string; - threadId?: number; + threadId?: ThreadId; mediaId?: number; senderId?: string; isChatWithSelf?: boolean; diff --git a/src/components/mediaViewer/MediaViewerContent.tsx b/src/components/mediaViewer/MediaViewerContent.tsx index fabf6ea5c..af92a5c75 100644 --- a/src/components/mediaViewer/MediaViewerContent.tsx +++ b/src/components/mediaViewer/MediaViewerContent.tsx @@ -5,7 +5,7 @@ import { withGlobal } from '../../global'; import type { ApiDimensions, ApiMessage, ApiPeer, } from '../../api/types'; -import { MediaViewerOrigin } from '../../types'; +import { MediaViewerOrigin, type ThreadId } from '../../types'; import { selectChat, selectChatMessage, selectIsMessageProtected, selectScheduledMessage, selectTabState, selectUser, @@ -31,7 +31,7 @@ import './MediaViewerContent.scss'; type OwnProps = { mediaId?: number; chatId?: string; - threadId?: number; + threadId?: ThreadId; avatarOwnerId?: string; origin?: MediaViewerOrigin; isActive?: boolean; @@ -45,7 +45,7 @@ type StateProps = { chatId?: string; mediaId?: number; senderId?: string; - threadId?: number; + threadId?: ThreadId; avatarOwner?: ApiPeer; message?: ApiMessage; origin?: MediaViewerOrigin; diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx index 9432101fc..5778485ba 100644 --- a/src/components/mediaViewer/MediaViewerSlides.tsx +++ b/src/components/mediaViewer/MediaViewerSlides.tsx @@ -3,7 +3,7 @@ import React, { memo, useEffect, useLayoutEffect, useRef, useState, } from '../../lib/teact/teact'; -import type { MediaViewerOrigin } from '../../types'; +import type { MediaViewerOrigin, ThreadId } from '../../types'; import type { RealTouchEvent } from '../../util/captureEvents'; import { animateNumber, timingFunctions } from '../../util/animation'; @@ -46,7 +46,7 @@ type OwnProps = { isOpen?: boolean; selectMedia: (id?: number) => void; chatId?: string; - threadId?: number; + threadId?: ThreadId; avatarOwnerId?: string; origin?: MediaViewerOrigin; withAnimation?: boolean; diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index 3428951fc..76c3faca8 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -9,7 +9,7 @@ import type { } from '../../api/types'; import type { MessageListType } from '../../global/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; -import type { FocusDirection } from '../../types'; +import type { FocusDirection, ThreadId } from '../../types'; import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; import { @@ -45,7 +45,7 @@ import ContextMenuContainer from './message/ContextMenuContainer.async'; type OwnProps = { message: ApiMessage; - threadId?: number; + threadId?: ThreadId; messageListType?: MessageListType; observeIntersectionForReading?: ObserveFn; observeIntersectionForLoading?: ObserveFn; diff --git a/src/components/middle/ContactGreeting.tsx b/src/components/middle/ContactGreeting.tsx index 17f7bad22..c73a77835 100644 --- a/src/components/middle/ContactGreeting.tsx +++ b/src/components/middle/ContactGreeting.tsx @@ -6,7 +6,7 @@ import type { ApiSticker, ApiUpdateConnectionStateType } from '../../api/types'; import type { MessageList } from '../../global/types'; import { getPeerIdDividend } from '../../global/helpers'; -import { selectChat, selectCurrentMessageList } from '../../global/selectors'; +import { selectChat, selectChatLastMessage, selectCurrentMessageList } from '../../global/selectors'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; @@ -101,10 +101,12 @@ export default memo(withGlobal( return {}; } + const lastMessage = selectChatLastMessage(global, chat.id); + return { sticker, - lastUnreadMessageId: chat.lastMessage && chat.lastMessage.id !== chat.lastReadInboxMessageId - ? chat.lastMessage.id + lastUnreadMessageId: lastMessage && lastMessage.id !== chat.lastReadInboxMessageId + ? lastMessage.id : undefined, connectionState: global.connectionState, currentMessageList: selectCurrentMessageList(global), diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index f5c24669d..0891e3dd9 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -5,13 +5,14 @@ import React, { import { getActions, withGlobal } from '../../global'; import type { MessageListType } from '../../global/types'; -import type { IAnchorPosition } from '../../types'; +import type { IAnchorPosition, ThreadId } from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { ManagementScreens } from '../../types'; import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom'; import { getHasAdminRight, + isAnonymousForwardsChat, isChatBasicGroup, isChatChannel, isChatSuperGroup, isUserId, } from '../../global/helpers'; import { @@ -44,7 +45,7 @@ import HeaderMenuContainer from './HeaderMenuContainer.async'; interface OwnProps { chatId: string; - threadId: number; + threadId: ThreadId; messageListType: MessageListType; canExpandActions: boolean; isForForum?: boolean; @@ -485,7 +486,8 @@ export default memo(withGlobal( (isMainThread || chat.isForum) && (isChannel || isChatSuperGroup(chat)) && chat.isNotJoined, ); const canSearch = isMainThread || isDiscussionThread; - const canCall = ARE_CALLS_SUPPORTED && isUserId(chat.id) && !isChatWithSelf && !bot; + const canCall = ARE_CALLS_SUPPORTED && isUserId(chat.id) && !isChatWithSelf && !bot && !chat.isSupport + && !isAnonymousForwardsChat(chat.id); const canMute = isMainThread && !isChatWithSelf && !canSubscribe; const canLeave = isMainThread && !canSubscribe; const canEnterVoiceChat = ARE_CALLS_SUPPORTED && isMainThread && chat.isCallActive; diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 74fdd3e83..d2eb8b2e9 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -5,7 +5,7 @@ import React, { import { getActions, withGlobal } from '../../global'; import type { ApiBotCommand, ApiChat } from '../../api/types'; -import type { IAnchorPosition } from '../../types'; +import type { IAnchorPosition, ThreadId } from '../../types'; import type { IconName } from '../../types/icons'; import { MAIN_THREAD_ID } from '../../api/types'; @@ -71,7 +71,7 @@ const BOT_BUTTONS: Record = { export type OwnProps = { chatId: string; - threadId: number; + threadId: ThreadId; isOpen: boolean; withExtraActions: boolean; anchor: IAnchorPosition; @@ -276,7 +276,7 @@ const HeaderMenuContainer: FC = ({ }); const handleEditTopicClick = useLastCallback(() => { - openEditTopicPanel({ chatId, topicId: threadId }); + openEditTopicPanel({ chatId, topicId: Number(threadId) }); setShouldCloseFast(!isRightColumnShown); closeMenu(); }); diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index 1a086e489..1aa680da9 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -77,7 +77,8 @@ } &.select-mode-active, - &.type-pinned { + &.type-pinned, + &.saved-dialog { margin-bottom: 0; .last-in-list { diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index a6cda0844..23ee1327a 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -15,16 +15,19 @@ import type { MessageListType } from '../../global/types'; import type { Signal } from '../../util/signals'; import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; import { MAIN_THREAD_ID } from '../../api/types'; -import { LoadMoreDirection } from '../../types'; +import { LoadMoreDirection, type ThreadId } from '../../types'; import { ANIMATION_END_DELAY, + ANONYMOUS_USER_ID, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID, } from '../../config'; import { forceMeasure, requestForcedReflow, requestMeasure } from '../../lib/fasterdom/fasterdom'; import { + getIsSavedDialog, getMessageHtmlId, + isAnonymousForwardsChat, isChatChannel, isChatGroup, isChatWithRepliesBot, @@ -35,6 +38,7 @@ import { selectBot, selectChat, selectChatFullInfo, + selectChatLastMessage, selectChatMessages, selectChatScheduledMessages, selectCurrentMessageIds, @@ -80,7 +84,7 @@ import './MessageList.scss'; type OwnProps = { chatId: string; - threadId: number; + threadId: ThreadId; type: MessageListType; isComments?: boolean; canPost: boolean; @@ -101,6 +105,7 @@ type StateProps = { isGroupChat?: boolean; isChatWithSelf?: boolean; isRepliesChat?: boolean; + isAnonymousForwards?: boolean; isCreator?: boolean; isBot?: boolean; isSynced?: boolean; @@ -119,6 +124,7 @@ type StateProps = { isServiceNotificationsChat?: boolean; isEmptyThread?: boolean; isForum?: boolean; + currentUserId: string; }; const MESSAGE_REACTIONS_POLLING_INTERVAL = 20 * 1000; @@ -152,6 +158,7 @@ const MessageList: FC = ({ isReady, isChatWithSelf, isRepliesChat, + isAnonymousForwards, isCreator, isBot, messageIds, @@ -171,8 +178,9 @@ const MessageList: FC = ({ topic, noMessageSendingAnimation, isServiceNotificationsChat, - onPinnedIntersectionChange, + currentUserId, getForceNextPinnedInHeader, + onPinnedIntersectionChange, }) => { const { loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, @@ -199,6 +207,9 @@ const MessageList: FC = ({ const isScrollTopJustUpdatedRef = useRef(false); const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage)); + const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); + const hasOpenChatButton = isSavedDialog && threadId !== ANONYMOUS_USER_ID; + const areMessagesLoaded = Boolean(messageIds); useSyncEffect(() => { @@ -250,7 +261,7 @@ const MessageList: FC = ({ ? groupMessages( orderBy(listedMessages, orderRule), memoUnreadDividerBeforeIdRef.current, - !isForum ? threadId : undefined, + !isForum ? Number(threadId) : undefined, isChatWithSelf, ) : undefined; @@ -547,9 +558,9 @@ const MessageList: FC = ({ }, [isSelectModeActive]); const isPrivate = Boolean(chatId && isUserId(chatId)); - const withUsers = Boolean((!isPrivate && !isChannelChat) || isChatWithSelf || isRepliesChat); + const withUsers = Boolean((!isPrivate && !isChannelChat) || isChatWithSelf || isRepliesChat || isAnonymousForwards); const noAvatars = Boolean(!withUsers || isChannelChat); - const shouldRenderGreeting = isUserId(chatId) && !isChatWithSelf && !isBot + const shouldRenderGreeting = isUserId(chatId) && !isChatWithSelf && !isBot && !isAnonymousForwards && ( ( !messageGroups && !lastMessage && messageIds @@ -575,6 +586,7 @@ const MessageList: FC = ({ isSelectModeActive && 'select-mode-active', isScrolled && 'scrolled', !isReady && 'is-animating', + hasOpenChatButton && 'saved-dialog', ); const hasMessages = (messageIds && messageGroups) || lastMessage; @@ -610,6 +622,7 @@ const MessageList: FC = ({ chatId={chatId} isComments={isComments} isChannelChat={isChannelChat} + isSavedDialog={isSavedDialog} messageIds={messageIds || [lastMessage!.id]} messageGroups={messageGroups || groupMessages([lastMessage!])} getContainerHeight={getContainerHeight} @@ -642,9 +655,10 @@ const MessageList: FC = ({ export default memo(withGlobal( (global, { chatId, threadId, type }): StateProps => { + const currentUserId = global.currentUserId!; const chat = selectChat(global, chatId); if (!chat) { - return {}; + return { currentUserId }; } const messageIds = selectCurrentMessageIds(global, chatId, threadId, type); @@ -652,14 +666,17 @@ export default memo(withGlobal( ? selectChatScheduledMessages(global, chatId) : selectChatMessages(global, chatId); + const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); + if ( - threadId !== MAIN_THREAD_ID && !chat?.isForum - && !(messagesById && threadId && messagesById[threadId]) + threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum + && !(messagesById && threadId && messagesById[Number(threadId)]) ) { - return {}; + return { currentUserId }; } - const { isRestricted, restrictionReason, lastMessage } = chat; + const { isRestricted, restrictionReason } = chat; + const lastMessage = selectChatLastMessage(global, chatId, isSavedDialog ? 'saved' : 'all'); const focusingId = selectFocusedMessageId(global, chatId); const withLastMessageWhenPreloading = ( @@ -683,6 +700,7 @@ export default memo(withGlobal( isCreator: chat.isCreator, isChatWithSelf: selectIsChatWithSelf(global, chatId), isRepliesChat: isChatWithRepliesBot(chatId), + isAnonymousForwards: isAnonymousForwardsChat(chatId), isBot: Boolean(chatBot), isSynced: global.isSynced, messageIds, @@ -697,6 +715,7 @@ export default memo(withGlobal( isServiceNotificationsChat: chatId === SERVICE_NOTIFICATIONS_USER_ID, isForum: chat.isForum, isEmptyThread, + currentUserId, ...(withLastMessageWhenPreloading && { lastMessage }), }; }, diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 027133787..2aaf8a5d0 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -4,6 +4,7 @@ import React, { memo } from '../../lib/teact/teact'; import { getActions } from '../../global'; import type { MessageListType } from '../../global/types'; +import type { ThreadId } from '../../types'; import type { Signal } from '../../util/signals'; import type { MessageDateGroup } from './helpers/groupMessages'; import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; @@ -33,7 +34,7 @@ import MessageListBotInfo from './MessageListBotInfo'; interface OwnProps { isCurrentUserPremium?: boolean; chatId: string; - threadId: number; + threadId: ThreadId; messageIds: number[]; messageGroups: MessageDateGroup[]; getContainerHeight: Signal; @@ -54,6 +55,7 @@ interface OwnProps { isSchedule: boolean; shouldRenderBotInfo?: boolean; noAppearanceAnimation: boolean; + isSavedDialog?: boolean; onFabToggle: AnyToVoidFunction; onNotchToggle: AnyToVoidFunction; onPinnedIntersectionChange: PinnedIntersectionChangedCallback; @@ -85,6 +87,7 @@ const MessageListContent: FC = ({ isSchedule, shouldRenderBotInfo, noAppearanceAnimation, + isSavedDialog, onFabToggle, onNotchToggle, onPinnedIntersectionChange, @@ -92,6 +95,7 @@ const MessageListContent: FC = ({ const { openHistoryCalendar } = getActions(); const getIsReady = useDerivedSignal(isReady); + const areDatesClickable = !isSavedDialog && !isSchedule; const { observeIntersectionForReading, @@ -162,7 +166,7 @@ const MessageListContent: FC = ({ message={message} threadId={threadId} messageListType={type} - isInsideTopic={Boolean(threadId && threadId !== MAIN_THREAD_ID)} + isInsideTopic={Boolean(threadId && threadId !== MAIN_THREAD_ID && !isSavedDialog)} observeIntersectionForReading={observeIntersectionForReading} observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForPlaying={observeIntersectionForPlaying} @@ -261,10 +265,10 @@ const MessageListContent: FC = ({ teactFastList >
openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined} + onClick={areDatesClickable ? () => openHistoryCalendar({ selectedAt: dateGroup.datetime }) : undefined} > {isSchedule && dateGroup.originalDate === SCHEDULED_WHEN_ONLINE && ( diff --git a/src/components/middle/MiddleColumn.scss b/src/components/middle/MiddleColumn.scss index d59612cf7..4fea37fa4 100644 --- a/src/components/middle/MiddleColumn.scss +++ b/src/components/middle/MiddleColumn.scss @@ -254,8 +254,7 @@ .Composer, .MessageSelectToolbar, -.unpin-all-button, -.join-subscribe-button, +.composer-button, .messaging-disabled { width: 100%; display: flex; @@ -264,8 +263,7 @@ } .MessageSelectToolbar-inner, -.unpin-all-button, -.join-subscribe-button, +.composer-button, .messaging-disabled { .mask-image-disabled & { box-shadow: 0 0.25rem 0.5rem 0.125rem var(--color-default-shadow); @@ -310,11 +308,11 @@ } } - .join-subscribe-button, - .unpin-all-button { + .composer-button { height: 3.5rem; transform: scaleX(1); transition: transform var(--select-transition), background-color 0.15s, color 0.15s; + color: var(--color-primary); .select-mode-active + .middle-column-footer & { box-shadow: none; diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index bddc14f5d..dbe53c357 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -9,11 +9,12 @@ import type { ActiveEmojiInteraction, MessageListType, } from '../../global/types'; -import type { ThemeKey } from '../../types'; +import type { ThemeKey, ThreadId } from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { ANIMATION_END_DELAY, + ANONYMOUS_USER_ID, EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_ID, GENERAL_TOPIC_ID, @@ -29,6 +30,7 @@ import { getCanPostInChat, getForumComposerPlaceholder, getHasAdminRight, + getIsSavedDialog, getMessageSendingRestrictionReason, isChatChannel, isChatGroup, @@ -101,7 +103,7 @@ interface OwnProps { type StateProps = { chatId?: string; - threadId?: number; + threadId?: ThreadId; isComments?: boolean; messageListType?: MessageListType; chat?: ApiChat; @@ -145,6 +147,8 @@ type StateProps = { topMessageId?: number; canUnpin?: boolean; canUnblock?: boolean; + isSavedDialog?: boolean; + canShowOpenChatButton?: boolean; }; function isImage(item: DataTransferItem) { @@ -201,6 +205,8 @@ function MiddleColumn({ topMessageId, canUnpin, canUnblock, + isSavedDialog, + canShowOpenChatButton, }: OwnProps & StateProps) { const { openChat, @@ -378,6 +384,10 @@ function MiddleColumn({ setIsUnpinModalOpen(false); }); + const handleOpenChatFromSaved = useLastCallback(() => { + openChat({ id: String(threadId) }); + }); + const handleUnpinAllMessages = useLastCallback(() => { unpinAllMessages({ chatId: chatId!, threadId: threadId! }); closeUnpinModal(); @@ -465,12 +475,12 @@ function MiddleColumn({ }); const isMessagingDisabled = Boolean( - !isPinnedMessageList && !renderingCanPost && !renderingCanRestartBot && !renderingCanStartBot + !isPinnedMessageList && !isSavedDialog && !renderingCanPost && !renderingCanRestartBot && !renderingCanStartBot && !renderingCanSubscribe && composerRestrictionMessage, ); const withMessageListBottomShift = Boolean( renderingCanRestartBot || renderingCanSubscribe || renderingShouldSendJoinRequest || renderingCanStartBot - || isPinnedMessageList || renderingCanUnblock, + || isPinnedMessageList || canShowOpenChatButton || renderingCanUnblock, ); const withExtraShift = Boolean(isMessagingDisabled || isSelectModeActive || isPinnedMessageList); @@ -563,7 +573,7 @@ function MiddleColumn({ size="tiny" fluid color="secondary" - className="unpin-all-button" + className="composer-button unpin-all-button" onClick={handleOpenUnpinModal} > @@ -571,6 +581,19 @@ function MiddleColumn({
)} + {canShowOpenChatButton && ( +
+ +
+ )} {isMessagingDisabled && (
@@ -588,7 +611,7 @@ function MiddleColumn({ size="tiny" fluid ripple - className="join-subscribe-button" + className="composer-button join-subscribe-button" onClick={handleSubscribeClick} > {lang(renderingIsChannel ? 'ProfileJoinChannel' : 'ProfileJoinGroup')} @@ -601,7 +624,7 @@ function MiddleColumn({ size="tiny" fluid ripple - className="join-subscribe-button" + className="composer-button join-subscribe-button" onClick={handleSubscribeClick} > {lang('ChannelJoinRequest')} @@ -614,7 +637,7 @@ function MiddleColumn({ size="tiny" fluid ripple - className="join-subscribe-button" + className="composer-button join-subscribe-button" onClick={handleStartBot} > {lang('BotStart')} @@ -627,7 +650,7 @@ function MiddleColumn({ size="tiny" fluid ripple - className="join-subscribe-button" + className="composer-button join-subscribe-button" onClick={handleRestartBot} > {lang('BotRestart')} @@ -640,7 +663,7 @@ function MiddleColumn({ size="tiny" fluid ripple - className="join-subscribe-button" + className="composer-button join-subscribe-button" onClick={handleUnblock} > {lang('Unblock')} @@ -763,8 +786,11 @@ export default memo(withGlobal( ? selectChatMessage(global, audioChatId, audioMessageId) : undefined; - const isCommentThread = threadId !== MAIN_THREAD_ID && !chat?.isForum; - const topMessageId = isCommentThread ? threadId : undefined; + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + const canShowOpenChatButton = isSavedDialog && threadId !== ANONYMOUS_USER_ID; + + const isCommentThread = threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum; + const topMessageId = isCommentThread ? Number(threadId) : undefined; const canUnpin = chat && ( isPrivate || ( @@ -787,7 +813,8 @@ export default memo(withGlobal( && (!chat || canPost) && !isBotNotStarted && !(shouldJoinToSend && chat?.isNotJoined) - && !shouldBlockSendInForum, + && !shouldBlockSendInForum + && !isSavedDialog, isPinnedMessageList, currentUserBannedRights: chat?.currentUserBannedRights, defaultBannedRights: chat?.defaultBannedRights, @@ -807,6 +834,8 @@ export default memo(withGlobal( topMessageId, canUnpin, canUnblock, + isSavedDialog, + canShowOpenChatButton, }; }, )(MiddleColumn)); diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index 3d47864a2..0e1426ec0 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -243,6 +243,17 @@ } } + .saved-dialog-avatar { + position: absolute; + } + + .overlay-avatar { + margin-left: 2.125rem; + .inner { + outline: 2px solid var(--color-background); + } + } + .status, .typing-status { display: inline; diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index a2329d8b0..4f8fcd62d 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -10,7 +10,7 @@ import type { import type { GlobalState, MessageListType } from '../../global/types'; import type { Signal } from '../../util/signals'; import { MAIN_THREAD_ID } from '../../api/types'; -import { StoryViewerOrigin } from '../../types'; +import { StoryViewerOrigin, type ThreadId } from '../../types'; import { EDITABLE_INPUT_CSS_SELECTOR, @@ -24,6 +24,7 @@ import { import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { getChatTitle, + getIsSavedDialog, getMessageKey, getSenderTitle, isChatChannel, @@ -83,7 +84,7 @@ const EMOJI_STATUS_SIZE = 22; type OwnProps = { chatId: string; - threadId: number; + threadId: ThreadId; messageListType: MessageListType; isComments?: boolean; isReady?: boolean; @@ -98,6 +99,7 @@ type StateProps = { pinnedMessageIds?: number[] | number; messagesById?: Record; canUnpin?: boolean; + isSavedDialog?: boolean; topMessageSender?: ApiPeer; typingStatus?: ApiTypingStatus; isSelectModeActive?: boolean; @@ -145,6 +147,7 @@ const MiddleHeader: FC = ({ getCurrentPinnedIndexes, getLoadingPinnedId, emojiStatusSticker, + isSavedDialog, onFocusPinnedMessage, }) => { const { @@ -352,7 +355,7 @@ const MiddleHeader: FC = ({ function renderInfo() { if (messageListType === 'thread') { - if (threadId === MAIN_THREAD_ID || chat?.isForum) { + if (threadId === MAIN_THREAD_ID || isSavedDialog || chat?.isForum) { return renderChatInfo(); } } @@ -377,25 +380,30 @@ const MiddleHeader: FC = ({ } function renderChatInfo() { + // TODO Implement count + const savedMessagesStatus = isSavedDialog ? lang('SavedMessages') : undefined; + + const realChatId = isSavedDialog ? String(threadId) : chatId; return ( <> - {(isLeftColumnHideable || currentTransitionKey > 0) && renderBackButton(shouldShowCloseButton, true)} + {(isLeftColumnHideable || currentTransitionKey > 0) && renderBackButton(shouldShowCloseButton, !isSavedDialog)}
- {isUserId(chatId) ? ( + {isUserId(realChatId) ? ( = ({ /> ) : ( ( const emojiStatus = chat?.emojiStatus; const emojiStatusSticker = emojiStatus && global.customEmojis.byId[emojiStatus.documentId]; + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + const state: StateProps = { typingStatus, isLeftColumnShown, @@ -569,6 +580,7 @@ export default memo(withGlobal( isFetchingDifference: global.isFetchingDifference, emojiStatusSticker, hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest, + isSavedDialog, }; const messagesById = selectChatMessages(global, chatId); @@ -576,8 +588,8 @@ export default memo(withGlobal( return state; } - if (threadId !== MAIN_THREAD_ID && !chat?.isForum) { - const pinnedMessageId = threadId; + if (threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum) { + const pinnedMessageId = Number(threadId); const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined; const topMessageSender = message ? selectForwardedSender(global, message) : undefined; @@ -590,7 +602,7 @@ export default memo(withGlobal( }; } - const pinnedMessageIds = selectPinnedIds(global, chatId, threadId); + const pinnedMessageIds = !isSavedDialog ? selectPinnedIds(global, chatId, threadId) : undefined; if (pinnedMessageIds?.length) { const firstPinnedMessage = messagesById[pinnedMessageIds[0]]; const { diff --git a/src/components/middle/MobileSearch.tsx b/src/components/middle/MobileSearch.tsx index 10e7f1bfb..c70fba170 100644 --- a/src/components/middle/MobileSearch.tsx +++ b/src/components/middle/MobileSearch.tsx @@ -6,6 +6,7 @@ import React, { import { getActions, withGlobal } from '../../global'; import type { ApiChat } from '../../api/types'; +import type { ThreadId } from '../../types'; import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { @@ -32,7 +33,7 @@ export type OwnProps = { type StateProps = { isActive?: boolean; chat?: ApiChat; - threadId?: number; + threadId?: ThreadId; query?: string; totalCount?: number; foundIds?: number[]; diff --git a/src/components/middle/composer/AttachBotItem.tsx b/src/components/middle/composer/AttachBotItem.tsx index 139ea9d1b..943aa8a05 100644 --- a/src/components/middle/composer/AttachBotItem.tsx +++ b/src/components/middle/composer/AttachBotItem.tsx @@ -5,7 +5,7 @@ import React, { import { getActions } from '../../../global'; import type { ApiAttachBot } from '../../../api/types'; -import type { IAnchorPosition, ISettings } from '../../../types'; +import type { IAnchorPosition, ISettings, ThreadId } from '../../../types'; import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; @@ -20,7 +20,7 @@ type OwnProps = { theme: ISettings['theme']; isInSideMenu?: true; chatId?: string; - threadId?: number; + threadId?: ThreadId; canShowNew?: boolean; onMenuOpened: VoidFunction; onMenuClosed: VoidFunction; diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index d9f7bad6d..e77a06bc2 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -6,7 +6,7 @@ import React, { import type { ApiAttachMenuPeerType } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; -import type { ISettings } from '../../../types'; +import type { ISettings, ThreadId } from '../../../types'; import { CONTENT_TYPES_WITH_PREVIEW, DEBUG_LOG_FILENAME, SUPPORTED_AUDIO_CONTENT_TYPES, @@ -32,7 +32,7 @@ import './AttachMenu.scss'; export type OwnProps = { chatId: string; - threadId?: number; + threadId?: ThreadId; isButtonVisible: boolean; canAttachMedia: boolean; canAttachPolls: boolean; diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 1646f1b05..6f607c630 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -8,6 +8,7 @@ import type { ApiAttachment, ApiChatMember, ApiSticker, } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; +import type { ThreadId } from '../../../types'; import type { Signal } from '../../../util/signals'; import { @@ -59,7 +60,7 @@ import styles from './AttachmentModal.module.scss'; export type OwnProps = { chatId: string; - threadId: number; + threadId: ThreadId; attachments: ApiAttachment[]; getHtml: Signal; canShowCustomSendMenu?: boolean; diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index 6b8dd4fd8..a2f28a4a1 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -7,7 +7,7 @@ import React, { import { getActions, withGlobal } from '../../../global'; import type { ApiInputMessageReplyInfo } from '../../../api/types'; -import type { IAnchorPosition, ISettings } from '../../../types'; +import type { IAnchorPosition, ISettings, ThreadId } from '../../../types'; import type { Signal } from '../../../util/signals'; import { EDITABLE_INPUT_ID } from '../../../config'; @@ -48,7 +48,7 @@ type OwnProps = { ref?: RefObject; id: string; chatId: string; - threadId: number; + threadId: ThreadId; isAttachmentModalInput?: boolean; isStoryInput?: boolean; customEmojiPrefix: string; diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index 3868ab9e9..a2b27d7d4 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -6,7 +6,7 @@ import React, { import { getActions, withGlobal } from '../../../global'; import type { ApiChat, ApiSticker, ApiStickerSet } from '../../../api/types'; -import type { StickerSetOrReactionsSetOrRecent } from '../../../types'; +import type { StickerSetOrReactionsSetOrRecent, ThreadId } from '../../../types'; import { CHAT_STICKER_SET_ID, @@ -48,7 +48,7 @@ import styles from './StickerPicker.module.scss'; type OwnProps = { chatId: string; - threadId?: number; + threadId?: ThreadId; className: string; isHidden?: boolean; isTranslucent?: boolean; diff --git a/src/components/middle/composer/StickerTooltip.tsx b/src/components/middle/composer/StickerTooltip.tsx index 4abb34d51..ad33e591e 100644 --- a/src/components/middle/composer/StickerTooltip.tsx +++ b/src/components/middle/composer/StickerTooltip.tsx @@ -3,6 +3,7 @@ import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; import { withGlobal } from '../../../global'; import type { ApiSticker } from '../../../api/types'; +import type { ThreadId } from '../../../types'; import { STICKER_SIZE_PICKER } from '../../../config'; import { selectIsChatWithSelf, selectIsCurrentUserPremium } from '../../../global/selectors'; @@ -21,7 +22,7 @@ import './StickerTooltip.scss'; export type OwnProps = { chatId: string; - threadId?: number; + threadId?: ThreadId; isOpen: boolean; onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; onClose: NoneToVoidFunction; diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index f27d60457..a5289a8dc 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -6,6 +6,7 @@ import { withGlobal } from '../../../global'; import type { ApiSticker, ApiVideo } from '../../../api/types'; import type { GlobalActions } from '../../../global'; +import type { ThreadId } from '../../../types'; import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { selectIsContextMenuTranslucent, selectTabState } from '../../../global/selectors'; @@ -35,7 +36,7 @@ const STICKERS_TAB_INDEX = 2; export type OwnProps = { chatId: string; - threadId?: number; + threadId?: ThreadId; isOpen: boolean; canSendStickers?: boolean; canSendGifs?: boolean; diff --git a/src/components/middle/composer/SymbolMenuButton.tsx b/src/components/middle/composer/SymbolMenuButton.tsx index ea316a0a4..0ea7c4d4b 100644 --- a/src/components/middle/composer/SymbolMenuButton.tsx +++ b/src/components/middle/composer/SymbolMenuButton.tsx @@ -3,7 +3,7 @@ import React, { memo, useRef, useState } from '../../../lib/teact/teact'; import { getActions } from '../../../global'; import type { ApiSticker, ApiVideo } from '../../../api/types'; -import type { IAnchorPosition } from '../../../types'; +import type { IAnchorPosition, ThreadId } from '../../../types'; import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_MODAL_CSS_SELECTOR } from '../../../config'; import buildClassName from '../../../util/buildClassName'; @@ -21,7 +21,7 @@ const MOBILE_KEYBOARD_HIDE_DELAY_MS = 100; type OwnProps = { chatId: string; - threadId?: number; + threadId?: ThreadId; isMobile?: boolean; isReady?: boolean; isSymbolMenuOpen?: boolean; diff --git a/src/components/middle/composer/WebPagePreview.tsx b/src/components/middle/composer/WebPagePreview.tsx index cb0733e2e..2992ae71f 100644 --- a/src/components/middle/composer/WebPagePreview.tsx +++ b/src/components/middle/composer/WebPagePreview.tsx @@ -5,7 +5,7 @@ import { getActions, withGlobal } from '../../../global'; import type { ApiFormattedText, ApiMessage, ApiMessageEntityTextUrl, ApiWebPage, } from '../../../api/types'; -import type { ISettings } from '../../../types'; +import type { ISettings, ThreadId } from '../../../types'; import type { Signal } from '../../../util/signals'; import { ApiMessageEntityTypes } from '../../../api/types'; @@ -29,7 +29,7 @@ import './WebPagePreview.scss'; type OwnProps = { chatId: string; - threadId: number; + threadId: ThreadId; getHtml: Signal; isDisabled?: boolean; }; diff --git a/src/components/middle/composer/hooks/useDraft.ts b/src/components/middle/composer/hooks/useDraft.ts index d314928c9..16cefa97d 100644 --- a/src/components/middle/composer/hooks/useDraft.ts +++ b/src/components/middle/composer/hooks/useDraft.ts @@ -3,6 +3,7 @@ import { getActions } from '../../../../global'; import type { ApiMessage } from '../../../../api/types'; import type { ApiDraft } from '../../../../global/types'; +import type { ThreadId } from '../../../../types'; import type { Signal } from '../../../../util/signals'; import { ApiMessageEntityTypes } from '../../../../api/types'; @@ -43,7 +44,7 @@ const useDraft = ({ } : { draft?: ApiDraft; chatId: string; - threadId: number; + threadId: ThreadId; getHtml: Signal; setHtml: (html: string) => void; editedMessage?: ApiMessage; @@ -68,7 +69,7 @@ const useDraft = ({ const isEditing = Boolean(editedMessage); - const updateDraft = useLastCallback((prevState: { chatId?: string; threadId?: number } = {}) => { + const updateDraft = useLastCallback((prevState: { chatId?: string; threadId?: ThreadId } = {}) => { if (isDisabled || isEditing || !isTouchedRef.current) return; const html = getHtml(); diff --git a/src/components/middle/composer/hooks/useEditing.ts b/src/components/middle/composer/hooks/useEditing.ts index 5f1635988..27559ae2c 100644 --- a/src/components/middle/composer/hooks/useEditing.ts +++ b/src/components/middle/composer/hooks/useEditing.ts @@ -3,6 +3,7 @@ import { getActions } from '../../../../global'; import type { ApiFormattedText, ApiMessage } from '../../../../api/types'; import type { ApiDraft, MessageListType } from '../../../../global/types'; +import type { ThreadId } from '../../../../types'; import type { Signal } from '../../../../util/signals'; import { ApiMessageEntityTypes } from '../../../../api/types'; @@ -30,7 +31,7 @@ const useEditing = ( resetComposer: (shouldPreserveInput?: boolean) => void, openDeleteModal: () => void, chatId: string, - threadId: number, + threadId: ThreadId, type: MessageListType, draft?: ApiDraft, editingDraft?: ApiFormattedText, diff --git a/src/components/middle/helpers/groupMessages.ts b/src/components/middle/helpers/groupMessages.ts index 489d28b5c..c017e591c 100644 --- a/src/components/middle/helpers/groupMessages.ts +++ b/src/components/middle/helpers/groupMessages.ts @@ -85,7 +85,7 @@ export function groupMessages( || (lastSenderGroupItem && 'mainMessage' in lastSenderGroupItem && lastSenderGroupItem.mainMessage?.id === topMessageId)) && nextMessage.id !== topMessageId) - || (isChatWithSelf && message.forwardInfo?.senderUserId !== nextMessage.forwardInfo?.senderUserId) + || (isChatWithSelf && message.forwardInfo?.fromId !== nextMessage.forwardInfo?.fromId) ) { currentSenderGroup = []; currentDateGroup.senderGroups.push(currentSenderGroup); diff --git a/src/components/middle/hooks/usePinnedMessage.ts b/src/components/middle/hooks/usePinnedMessage.ts index 292810016..4c2a9312b 100644 --- a/src/components/middle/hooks/usePinnedMessage.ts +++ b/src/components/middle/hooks/usePinnedMessage.ts @@ -1,6 +1,8 @@ import { useEffect, useRef } from '../../../lib/teact/teact'; import { getGlobal } from '../../../global'; +import type { ThreadId } from '../../../types'; + import { selectFocusedMessageId, selectListedIds, @@ -24,7 +26,7 @@ type PinnedIntersectionChangedParams = { export type PinnedIntersectionChangedCallback = (params: PinnedIntersectionChangedParams) => void; export default function usePinnedMessage( - chatId?: string, threadId?: number, pinnedIds?: number[], topMessageId?: number, + chatId?: string, threadId?: ThreadId, pinnedIds?: number[], topMessageId?: number, ) { const [getCurrentPinnedIndexes, setCurrentPinnedIndexes] = useSignal>({}); const [getForceNextPinnedInHeader, setForceNextPinnedInHeader] = useSignal(); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 499dccebb..d38f94185 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -23,7 +23,9 @@ import type { MessageListType, } from '../../../global/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import type { FocusDirection, IAlbum, ISettings } from '../../../types'; +import type { + FocusDirection, IAlbum, ISettings, ThreadId, +} from '../../../types'; import type { Signal } from '../../../util/signals'; import type { PinnedIntersectionChangedCallback } from '../hooks/usePinnedMessage'; import { MAIN_THREAD_ID } from '../../../api/types'; @@ -32,6 +34,7 @@ import { AudioOrigin } from '../../../types'; import { EMOJI_STATUS_LOOP_LIMIT, GENERAL_TOPIC_ID } from '../../../config'; import { areReactionsEmpty, + getIsSavedDialog, getMessageContent, getMessageCustomShape, getMessageHtmlId, @@ -41,6 +44,7 @@ import { getSenderTitle, hasMessageText, hasMessageTtl, + isAnonymousForwardsChat, isAnonymousOwnMessage, isChatChannel, isChatGroup, @@ -191,7 +195,7 @@ type OwnProps = noAvatars?: boolean; withAvatar?: boolean; withSenderName?: boolean; - threadId: number; + threadId: ThreadId; messageListType: MessageListType; noComments: boolean; noReplies: boolean; @@ -232,6 +236,7 @@ type StateProps = { isForwarding?: boolean; isChatWithSelf?: boolean; isRepliesChat?: boolean; + isAnonymousForwards?: boolean; isChannel?: boolean; isGroup?: boolean; canReply?: boolean; @@ -243,7 +248,7 @@ type StateProps = { isSelected?: boolean; isGroupSelected?: boolean; isDownloading?: boolean; - threadId?: number; + threadId?: ThreadId; isPinnedList?: boolean; isPinned?: boolean; canAutoLoadMedia?: boolean; @@ -274,6 +279,7 @@ type StateProps = { isConnected: boolean; isLoadingComments?: boolean; shouldWarnAboutSvg?: boolean; + isInSavedDialog?: boolean; }; type MetaPosition = @@ -347,6 +353,7 @@ const Message: FC = ({ isForwarding, isChatWithSelf, isRepliesChat, + isAnonymousForwards, isChannel, isGroup, canReply, @@ -387,6 +394,7 @@ const Message: FC = ({ isConnected, getIsMessageListReady, shouldWarnAboutSvg, + isInSavedDialog, onPinnedIntersectionChange, }) => { const { @@ -483,6 +491,7 @@ const Message: FC = ({ forwardInfo && (!isChatWithSelf || isScheduled) && !isRepliesChat + && !isAnonymousForwards && !forwardInfo.isLinkedChannelPost && !isCustomShape ) || Boolean(message.content.storyData && !message.content.storyData.isMention); @@ -500,7 +509,7 @@ const Message: FC = ({ const canForward = isChannel && !isScheduled && message.isForwardingAllowed && !isChatProtected; const canFocus = Boolean(isPinnedList || (forwardInfo - && (forwardInfo.isChannelPost || (isChatWithSelf && !isOwn) || isRepliesChat) + && (forwardInfo.isChannelPost || (isChatWithSelf && !isOwn) || isRepliesChat || isAnonymousForwards) && forwardInfo.fromMessageId )); @@ -520,7 +529,8 @@ const Message: FC = ({ const messageSender = canShowSender ? sender : undefined; const withVoiceTranscription = Boolean(!isTranscriptionHidden && (isTranscriptionError || transcribedText)); - const shouldPreferOriginSender = forwardInfo && (isChatWithSelf || isRepliesChat || !messageSender); + const shouldPreferOriginSender = forwardInfo + && (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender); const avatarPeer = shouldPreferOriginSender ? originSender : messageSender; const messageColorPeer = originSender || sender; const senderPeer = (forwardInfo || message.content.storyData) ? originSender : messageSender; @@ -914,6 +924,7 @@ const Message: FC = ({ ( const chat = selectChat(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isRepliesChat = isChatWithRepliesBot(chatId); + const isAnonymousForwards = isAnonymousForwardsChat(chatId); const isChannel = chat && isChatChannel(chat); const isGroup = chat && isChatGroup(chat); const chatFullInfo = !isUserId(chatId) ? selectChatFullInfo(global, chatId) : undefined; @@ -1487,11 +1499,12 @@ export default memo(withGlobal( const shouldHideReply = replyToMsgId && replyToMsgId === threadId; const replyMessage = replyToMsgId ? selectChatMessage(global, replyToPeerId || chatId, replyToMsgId) : undefined; const forwardHeader = forwardInfo || replyFrom; - const replyMessageSender = replyMessage ? selectReplySender(global, replyMessage) : forwardHeader && !isRepliesChat - ? selectSenderFromHeader(global, forwardHeader) : undefined; + const replyMessageSender = replyMessage ? selectReplySender(global, replyMessage) + : forwardHeader && !isRepliesChat && !isAnonymousForwards + ? selectSenderFromHeader(global, forwardHeader) : undefined; const replyMessageForwardSender = replyMessage && selectForwardedSender(global, replyMessage); const replyMessageChat = replyToPeerId ? selectChat(global, replyToPeerId) : undefined; - const isReplyPrivate = !isRepliesChat && replyMessageChat && !isChatPublic(replyMessageChat) + const isReplyPrivate = !isRepliesChat && !isAnonymousForwards && replyMessageChat && !isChatPublic(replyMessageChat) && (replyMessageChat.isNotJoined || replyMessageChat.isRestricted); const isReplyToTopicStart = replyMessage?.content.action?.type === 'topicCreate'; const replyStory = storyReplyId && storyReplyUserId @@ -1554,6 +1567,8 @@ export default memo(withGlobal( const hasActiveReactions = Boolean(reactionMessage && activeReactions[getMessageKey(reactionMessage)]?.length); + const isInSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + return { theme: selectTheme(global), forceSenderName, @@ -1578,6 +1593,7 @@ export default memo(withGlobal( reactionMessage, isChatWithSelf, isRepliesChat, + isAnonymousForwards, isChannel, isGroup, canReply, @@ -1621,6 +1637,7 @@ export default memo(withGlobal( withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), webPageStory, isConnected, + isInSavedDialog, isLoadingComments: repliesThreadInfo?.isCommentsInfo && loadingThread?.loadingChatId === repliesThreadInfo?.originChannelId && loadingThread?.loadingMessageId === repliesThreadInfo?.originMessageId, diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index 3ed490ca3..d45c06aa1 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -29,6 +29,7 @@ type OwnProps = { repliesThreadInfo?: ApiThreadInfo; isTranslated?: boolean; isPinned?: boolean; + isInSavedDialog?: boolean; onClick: (e: React.MouseEvent) => void; onTranslationClick: (e: React.MouseEvent) => void; renderQuickReactionButton?: () => TeactNode | undefined; @@ -45,6 +46,7 @@ const MessageMeta: FC = ({ noReplies, isTranslated, isPinned, + isInSavedDialog, onClick, onTranslationClick, onOpenThread, @@ -72,7 +74,12 @@ const MessageMeta: FC = ({ const editDateTime = message.isEdited && formatDateTimeToString(message.editDate! * 1000, lang.code, undefined, lang.timeFormat); const forwardedDateTime = message.forwardInfo - && formatDateTimeToString(message.forwardInfo.date * 1000, lang.code, undefined, lang.timeFormat); + && formatDateTimeToString( + (message.forwardInfo.savedDate || message.forwardInfo.date) * 1000, + lang.code, + undefined, + lang.timeFormat, + ); let text = createDateTime; if (editDateTime) { @@ -137,7 +144,9 @@ const MessageMeta: FC = ({ )} {message.isEdited && `${lang('EditedMessage')} `} - {formatTime(lang, message.date * 1000)} + {isInSavedDialog + ? formatDateTimeToString((message.forwardInfo?.date || message.date) * 1000, lang.code, true) + : formatTime(lang, message.date * 1000)} {outgoingStatus && ( diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index 3cead036e..3440c25ca 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -5,7 +5,7 @@ import type { ApiMessage, ApiPeer, ApiStory, ApiTopic, ApiUser, } from '../../../../api/types'; import type { LangFn } from '../../../../hooks/useLang'; -import type { IAlbum } from '../../../../types'; +import type { IAlbum, ThreadId } from '../../../../types'; import { MAIN_THREAD_ID } from '../../../../api/types'; import { MediaViewerOrigin } from '../../../../types'; @@ -18,7 +18,7 @@ export default function useInnerHandlers( selectMessage: (e: React.MouseEvent, groupedId?: string) => void, message: ApiMessage, chatId: string, - threadId: number, + threadId: ThreadId, isInDocumentGroup: boolean, asForwarded?: boolean, isScheduled?: boolean, diff --git a/src/components/right/AddChatMembers.tsx b/src/components/right/AddChatMembers.tsx index ee01a7c73..a210497c9 100644 --- a/src/components/right/AddChatMembers.tsx +++ b/src/components/right/AddChatMembers.tsx @@ -5,15 +5,16 @@ import React, { import { getActions, getGlobal, withGlobal } from '../../global'; import type { - ApiChat, ApiChatMember, + ApiChatMember, } from '../../api/types'; import { NewChatMembersProgress } from '../../types'; import { - filterUsersByName, isChatChannel, isUserBot, sortChatIds, + filterUsersByName, isChatChannel, isUserBot, } from '../../global/helpers'; import { selectChat, selectChatFullInfo, selectTabState } from '../../global/selectors'; import { unique } from '../../util/iteratees'; +import sortChatIds from '../common/helpers/sortChatIds'; import useHistoryBack from '../../hooks/useHistoryBack'; import useLang from '../../hooks/useLang'; @@ -36,7 +37,6 @@ type StateProps = { isChannel?: boolean; members?: ApiChatMember[]; currentUserId?: string; - chatsById: Record; localContactIds?: string[]; searchQuery?: string; isLoading: boolean; @@ -50,7 +50,6 @@ const AddChatMembers: FC = ({ members, onNextStep, currentUserId, - chatsById, localContactIds, isLoading, searchQuery, @@ -104,11 +103,8 @@ const AddChatMembers: FC = ({ && (!user || !isUserBot(user) || (!isChannel && user.canBeInvitedToGroup)) ); }), - chatsById, ); - }, [ - localContactIds, chatsById, searchQuery, localUserIds, globalUserIds, currentUserId, memberIds, isChannel, - ]); + }, [localContactIds, searchQuery, localUserIds, globalUserIds, currentUserId, memberIds, isChannel]); const handleNextStep = useCallback(() => { if (selectedMemberIds.length) { @@ -154,7 +150,6 @@ export default memo(withGlobal( (global, { chatId }): StateProps => { const chat = selectChat(global, chatId); const { userIds: localContactIds } = global.contactList || {}; - const { byId: chatsById } = global.chats; const { newChatMembersProgress } = selectTabState(global); const { currentUserId } = global; const isChannel = chat && isChatChannel(chat); @@ -170,7 +165,6 @@ export default memo(withGlobal( isChannel, members: selectChatFullInfo(global, chatId)?.members, currentUserId, - chatsById, localContactIds, searchQuery, isSearching: fetchingStatus, diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index dc3738a15..62619fd44 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -63,6 +63,10 @@ flex: 1; } + .saved-dialogs { + height: 100% !important; + } + .content { &.empty-list { height: 100%; @@ -158,6 +162,6 @@ margin-top: 1rem; font-size: 0.8125rem; } - } + } } } diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index 8171310c6..34698913b 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -14,7 +14,7 @@ import type { ApiUserStatus, } from '../../api/types'; import type { - ISettings, ProfileState, ProfileTabType, SharedMediaType, + ISettings, ProfileState, ProfileTabType, SharedMediaType, ThreadId, } from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { AudioOrigin, MediaViewerOrigin, NewChatMembersProgress } from '../../types'; @@ -26,7 +26,7 @@ import { SLIDE_TRANSITION_DURATION, } from '../../config'; import { - getHasAdminRight, isChatAdmin, isChatChannel, isChatGroup, isUserBot, isUserId, isUserRightBanned, + getHasAdminRight, getIsSavedDialog, isChatAdmin, isChatChannel, isChatGroup, isUserBot, isUserId, isUserRightBanned, } from '../../global/helpers'; import { selectActiveDownloads, @@ -70,6 +70,7 @@ import NothingFound from '../common/NothingFound'; import PrivateChatInfo from '../common/PrivateChatInfo'; import ProfileInfo from '../common/ProfileInfo'; import WebLink from '../common/WebLink'; +import ChatList from '../left/main/ChatList'; import MediaStory from '../story/MediaStory'; import Button from '../ui/Button'; import FloatingActionButton from '../ui/FloatingActionButton'; @@ -84,7 +85,7 @@ import './Profile.scss'; type OwnProps = { chatId: string; - topicId?: number; + threadId?: ThreadId; profileState: ProfileState; isMobile?: boolean; onProfileStateChange: (state: ProfileState) => void; @@ -122,9 +123,16 @@ type StateProps = { similarChannels?: string[]; isCurrentUserPremium?: boolean; limitSimilarChannels: number; + isTopicInfo?: boolean; + isSavedDialog?: boolean; }; -const TABS = [ +type TabProps = { + type: ProfileTabType; + title: string; +}; + +const TABS: TabProps[] = [ { type: 'media', title: 'SharedMediaTab2' }, { type: 'documents', title: 'SharedFilesTab2' }, { type: 'links', title: 'SharedLinksTab2' }, @@ -136,7 +144,7 @@ const INTERSECTION_THROTTLE = 500; const Profile: FC = ({ chatId, - topicId, + threadId, profileState, onProfileStateChange, theme, @@ -170,6 +178,8 @@ const Profile: FC = ({ similarChannels, isCurrentUserPremium, limitSimilarChannels, + isTopicInfo, + isSavedDialog, }) => { const { setLocalMediaSearchType, @@ -195,29 +205,33 @@ const Profile: FC = ({ const lang = useLang(); const [deletingUserId, setDeletingUserId] = useState(); + const profileId = isSavedDialog ? String(threadId) : (resolvedUserId || chatId); + const isSavedMessages = profileId === currentUserId && !isSavedDialog; + const tabs = useMemo(() => ([ - ...(hasStoriesTab ? [{ type: 'stories', title: 'ProfileStories' }] : []), - ...(hasStoriesTab && currentUserId === chatId ? [{ type: 'storiesArchive', title: 'ProfileStoriesArchive' }] : []), + ...(isSavedMessages && !isSavedDialog ? [{ type: 'dialogs' as const, title: 'SavedDialogsTab' }] : []), + ...(hasStoriesTab ? [{ type: 'stories' as const, title: 'ProfileStories' }] : []), + ...(hasStoriesTab && isSavedMessages ? [{ type: 'storiesArchive' as const, title: 'ProfileStoriesArchive' }] : []), ...(hasMembersTab ? [{ - type: 'members', title: isChannel ? 'ChannelSubscribers' : 'GroupMembers', + type: 'members' as const, title: isChannel ? 'ChannelSubscribers' : 'GroupMembers', }] : []), ...TABS, // TODO The filter for voice messages currently does not work // in forum topics. Return it when it's fixed on the server side. - ...(!topicId ? [{ type: 'voice', title: 'SharedVoiceTab2' }] : []), - ...(hasCommonChatsTab ? [{ type: 'commonChats', title: 'SharedGroupsTab2' }] : []), + ...(!isTopicInfo ? [{ type: 'voice' as const, title: 'SharedVoiceTab2' }] : []), + ...(hasCommonChatsTab ? [{ type: 'commonChats' as const, title: 'SharedGroupsTab2' }] : []), ...(isChannel && similarChannels?.length - ? [{ type: 'similarChannels', title: 'SimilarChannelsTab' }] + ? [{ type: 'similarChannels' as const, title: 'SimilarChannelsTab' }] : []), ]), [ - chatId, - currentUserId, hasCommonChatsTab, hasMembersTab, hasStoriesTab, isChannel, - topicId, + isTopicInfo, similarChannels, + isSavedMessages, + isSavedDialog, ]); const initialTab = useMemo(() => { @@ -269,12 +283,13 @@ const Profile: FC = ({ chatsById, messagesById, foundIds, - topicId, + threadId, storyIds, archiveStoryIds, similarChannels, ); - const isFirstTab = (hasStoriesTab && resultType === 'stories') + const isFirstTab = (isSavedMessages && resultType === 'dialogs') + || (hasStoriesTab && resultType === 'stories') || resultType === 'members' || (!hasMembersTab && resultType === 'media'); const activeKey = tabs.findIndex(({ type }) => type === resultType); @@ -304,9 +319,7 @@ const Profile: FC = ({ // Update search type when switching tabs or forum topics useEffect(() => { setLocalMediaSearchType({ mediaType: tabType as SharedMediaType }); - }, [setLocalMediaSearchType, tabType, topicId]); - - const profileId = resolvedUserId || chatId; + }, [setLocalMediaSearchType, tabType, threadId]); useEffect(() => { loadProfilePhotos({ profileId }); @@ -376,7 +389,7 @@ const Profile: FC = ({ } else if (!viewportIds) { renderingDelay = SLIDE_TRANSITION_DURATION; } - const canRenderContent = useAsyncRendering([chatId, topicId, resultType, renderingActiveTab], renderingDelay); + const canRenderContent = useAsyncRendering([chatId, threadId, resultType, renderingActiveTab], renderingDelay); function getMemberContextAction(memberId: string): MenuItemContextAction[] | undefined { return memberId === currentUserId || !canDeleteMembers ? undefined : [{ @@ -389,6 +402,12 @@ const Profile: FC = ({ } function renderContent() { + if (resultType === 'dialogs') { + return ( + + ); + } + if (!viewportIds || !canRenderContent || !messagesById) { const noSpinner = isFirstTab && !canRenderContent; const forceRenderHiddenMembers = Boolean(resultType === 'members' && areMembersHidden); @@ -594,7 +613,9 @@ const Profile: FC = ({ onLoadMore={getMore} onScroll={handleScroll} > - {!noProfileInfo && renderProfileInfo(chatId, resolvedUserId, isRightColumnShown && canRenderContent)} + {!noProfileInfo && !isSavedMessages && ( + renderProfileInfo(profileId, isRightColumnShown && canRenderContent, isSavedDialog) + )} {!isRestricted && (
= ({ ); }; -function renderProfileInfo(chatId: string, resolvedUserId: string | undefined, isReady: boolean) { +function renderProfileInfo(profileId: string, isReady: boolean, isSavedDialog?: boolean) { return (
- - + +
); } export default memo(withGlobal( - (global, { chatId, topicId, isMobile }): StateProps => { + (global, { + chatId, threadId, isMobile, + }): StateProps => { const chat = selectChat(global, chatId); const chatFullInfo = selectChatFullInfo(global, chatId); const messagesById = selectChatMessages(global, chatId); const { currentType: mediaSearchType, resultsByType } = selectCurrentMediaSearch(global) || {}; const { foundIds } = (resultsByType && mediaSearchType && resultsByType[mediaSearchType]) || {}; + const isTopicInfo = Boolean(chat?.isForum && threadId && threadId !== MAIN_THREAD_ID); + const { byId: usersById, statusesById: userStatusesById } = global.users; const { byId: chatsById } = global.chats; + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + const isGroup = chat && isChatGroup(chat); const isChannel = chat && isChatChannel(chat); - const hasMembersTab = !topicId && (isGroup || (isChannel && isChatAdmin(chat!))); + const hasMembersTab = !isTopicInfo && !isSavedDialog && (isGroup || (isChannel && isChatAdmin(chat!))); const members = chatFullInfo?.members; const adminMembersById = chatFullInfo?.adminMembersById; const areMembersHidden = hasMembersTab && chat @@ -675,12 +702,13 @@ export default memo(withGlobal( if (isUserId(chatId)) { resolvedUserId = chatId; user = selectUser(global, resolvedUserId); - hasCommonChatsTab = user && !user.isSelf && !isUserBot(user); + hasCommonChatsTab = user && !user.isSelf && !isUserBot(user) && !isSavedDialog; } const peer = user || chat; const peerFullInfo = selectPeerFullInfo(global, chatId); - const hasStoriesTab = peer && (user?.isSelf || (!peer.areStoriesHidden && peerFullInfo?.hasPinnedStories)); + const hasStoriesTab = peer && (user?.isSelf || (!peer.areStoriesHidden && peerFullInfo?.hasPinnedStories)) + && !isSavedDialog; const peerStories = hasStoriesTab ? selectPeerStories(global, peer.id) : undefined; const storyIds = peerStories?.pinnedIds; const storyByIds = peerStories?.byId; @@ -714,6 +742,8 @@ export default memo(withGlobal( shouldWarnAboutSvg: global.settings.byKey.shouldWarnAboutSvg, similarChannels, isCurrentUserPremium, + isTopicInfo, + isSavedDialog, limitSimilarChannels: selectPremiumLimit(global, 'recommendedChannels'), ...(hasMembersTab && members && { members, adminMembersById }), ...(hasCommonChatsTab && user && { commonChatIds: user.commonChats?.ids }), diff --git a/src/components/right/RightColumn.tsx b/src/components/right/RightColumn.tsx index 79d6c2fbe..204de998f 100644 --- a/src/components/right/RightColumn.tsx +++ b/src/components/right/RightColumn.tsx @@ -2,15 +2,19 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo, useEffect, useState } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { ProfileTabType } from '../../types'; -import { MAIN_THREAD_ID } from '../../api/types'; +import type { ProfileTabType, ThreadId } from '../../types'; import { ManagementScreens, NewChatMembersProgress, ProfileState, RightColumnContent, } from '../../types'; import { ANIMATION_END_DELAY, MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN } from '../../config'; +import { getIsSavedDialog } from '../../global/helpers'; import { - selectAreActiveChatsLoaded, selectChat, selectCurrentMessageList, selectRightColumnContentKey, selectTabState, + selectAreActiveChatsLoaded, + selectCurrentMessageList, + selectIsChatWithSelf, + selectRightColumnContentKey, + selectTabState, } from '../../global/selectors'; import captureEscKeyListener from '../../util/captureEscKeyListener'; @@ -45,12 +49,14 @@ interface OwnProps { type StateProps = { contentKey?: RightColumnContent; chatId?: string; - threadId?: number; + threadId?: ThreadId; isInsideTopic?: boolean; isChatSelected: boolean; shouldSkipHistoryAnimations?: boolean; nextManagementScreen?: ManagementScreens; nextProfileTab?: ProfileTabType; + isSavedMessages?: boolean; + isSavedDialog?: boolean; }; const ANIMATION_DURATION = 450 + ANIMATION_END_DELAY; @@ -69,11 +75,12 @@ const RightColumn: FC = ({ chatId, threadId, isMobile, - isInsideTopic, isChatSelected, shouldSkipHistoryAnimations, nextManagementScreen, nextProfileTab, + isSavedMessages, + isSavedDialog, }) => { const { toggleChatInfo, @@ -97,7 +104,9 @@ const RightColumn: FC = ({ } = getActions(); const { width: windowWidth } = useWindowSize(); - const [profileState, setProfileState] = useState(ProfileState.Profile); + const [profileState, setProfileState] = useState( + isSavedMessages && !isSavedDialog ? ProfileState.SavedDialogs : ProfileState.Profile, + ); const [managementScreen, setManagementScreen] = useState(ManagementScreens.Initial); const [selectedChatMemberId, setSelectedChatMemberId] = useState(); const [isPromotedByCurrentUser, setIsPromotedByCurrentUser] = useState(); @@ -129,7 +138,7 @@ const RightColumn: FC = ({ setNewChatMembersDialogState({ newChatMembersProgress: NewChatMembersProgress.Closed }); break; case RightColumnContent.ChatInfo: - if (isScrolledDown && shouldScrollUp) { + if (isScrolledDown && shouldScrollUp && !isSavedMessages) { setProfileState(ProfileState.Profile); break; } @@ -253,12 +262,14 @@ const RightColumn: FC = ({ }, [isOverlaying]); // We need to clear profile state and management screen state, when changing chats - useLayoutEffectWithPrevDeps(([prevChatId]) => { - if (prevChatId !== chatId) { - setProfileState(ProfileState.Profile); + useLayoutEffectWithPrevDeps(([prevChatId, prevThreadId]) => { + if (prevChatId !== chatId || prevThreadId !== threadId) { + setProfileState( + isSavedMessages && !isSavedDialog ? ProfileState.SavedDialogs : ProfileState.Profile, + ); setManagementScreen(ManagementScreens.Initial); } - }, [chatId]); + }, [chatId, threadId, isSavedDialog, isSavedMessages]); useHistoryBack({ isActive: isChatSelected && ( @@ -289,9 +300,9 @@ const RightColumn: FC = ({ case RightColumnContent.ChatInfo: return ( ( const areActiveChatsLoaded = selectAreActiveChatsLoaded(global); const { management, shouldSkipHistoryAnimations, nextProfileTab } = selectTabState(global); const nextManagementScreen = chatId ? management.byChatId[chatId]?.nextScreen : undefined; - const isForum = chatId ? selectChat(global, chatId)?.isForum : undefined; - const isInsideTopic = isForum && Boolean(threadId && threadId !== MAIN_THREAD_ID); + + const isSavedMessages = chatId ? selectIsChatWithSelf(global, chatId) : undefined; + const isSavedDialog = chatId ? getIsSavedDialog(chatId, threadId, global.currentUserId) : undefined; return { contentKey: selectRightColumnContentKey(global, isMobile), chatId, threadId, - isInsideTopic, isChatSelected: Boolean(chatId && areActiveChatsLoaded), shouldSkipHistoryAnimations, nextManagementScreen, nextProfileTab, + isSavedMessages, + isSavedDialog, }; }, )(RightColumn)); diff --git a/src/components/right/RightHeader.scss b/src/components/right/RightHeader.scss index b849f66fc..77cbc27b7 100644 --- a/src/components/right/RightHeader.scss +++ b/src/components/right/RightHeader.scss @@ -23,13 +23,27 @@ } } - h3 { + .title { margin-bottom: 0; font-size: 1.25rem; font-weight: 500; margin-left: 1.375rem; } + .header { + margin-left: 1.375rem; + + .title { + font-size: 1rem; + margin-left: 0; + } + + .subtitle { + color: var(--color-text-secondary); + font-size: 0.875rem; + } + } + .tools { display: flex; margin-left: auto; diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index bd6e50b26..2c6fcaf2e 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -4,9 +4,9 @@ import { getActions, withGlobal } from '../../global'; import type { ApiExportedInvite } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types'; -import { ManagementScreens, ProfileState } from '../../types'; +import { ManagementScreens, ProfileState, type ThreadId } from '../../types'; -import { ANIMATION_END_DELAY } from '../../config'; +import { ANIMATION_END_DELAY, SAVED_FOLDER_ID } from '../../config'; import { getCanAddContact, getCanManageTopic, isChatChannel, isUserBot, isUserId, } from '../../global/helpers'; @@ -17,6 +17,7 @@ import { selectCurrentGifSearch, selectCurrentStickerSearch, selectCurrentTextSearch, + selectIsChatWithSelf, selectTabState, selectUser, } from '../../global/selectors'; @@ -28,6 +29,7 @@ import useAppLayout from '../../hooks/useAppLayout'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useElectronDrag from '../../hooks/useElectronDrag'; import useFlag from '../../hooks/useFlag'; +import { useFolderManagerForChatsCount } from '../../hooks/useFolderManager'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; @@ -41,7 +43,7 @@ import './RightHeader.scss'; type OwnProps = { chatId?: string; - threadId?: number; + threadId?: ThreadId; isColumnOpen?: boolean; isProfile?: boolean; isSearch?: boolean; @@ -58,7 +60,7 @@ type OwnProps = { isAddingChatMembers?: boolean; profileState?: ProfileState; managementScreen?: ManagementScreens; - onClose: () => void; + onClose: (shouldScrollUp?: boolean) => void; onScreenSelect: (screen: ManagementScreens) => void; }; @@ -79,6 +81,7 @@ type StateProps = { canEditBot?: boolean; isInsideTopic?: boolean; canEditTopic?: boolean; + isSavedMessages?: boolean; }; const COLUMN_ANIMATION_DURATION = 450 + ANIMATION_END_DELAY; @@ -121,6 +124,7 @@ enum HeaderContent { ManageJoinRequests, CreateTopic, EditTopic, + SavedDialogs, } const RightHeader: FC = ({ @@ -157,6 +161,7 @@ const RightHeader: FC = ({ isBot, isInsideTopic, canEditTopic, + isSavedMessages, onClose, onScreenSelect, canEditBot, @@ -178,6 +183,8 @@ const RightHeader: FC = ({ const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); const { isMobile } = useAppLayout(); + const foldersChatCount = useFolderManagerForChatsCount(); + const handleEditInviteClick = useLastCallback(() => { setEditingExportedInvite({ chatId: chatId!, invite: currentInviteInfo! }); onScreenSelect(ManagementScreens.EditInvite); @@ -211,7 +218,7 @@ const RightHeader: FC = ({ const toggleEditTopic = useLastCallback(() => { if (!chatId || !threadId) return; - openEditTopicPanel({ chatId, topicId: threadId }); + openEditTopicPanel({ chatId, topicId: Number(threadId) }); }); const handleToggleManagement = useLastCallback(() => { @@ -222,6 +229,10 @@ const RightHeader: FC = ({ toggleStatistics(); }); + const handleClose = useLastCallback(() => { + onClose(!isSavedMessages); + }); + const [shouldSkipTransition, setShouldSkipTransition] = useState(!isColumnOpen); useEffect(() => { @@ -240,6 +251,8 @@ const RightHeader: FC = ({ HeaderContent.MemberList ) : profileState === ProfileState.StoryList ? ( HeaderContent.StoryList + ) : profileState === ProfileState.SavedDialogs ? ( + HeaderContent.SavedDialogs ) : -1 // Never reached ) : isSearch ? ( HeaderContent.Search @@ -310,6 +323,10 @@ const RightHeader: FC = ({ const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1; function getHeaderTitle() { + if (isSavedMessages) { + return lang('SavedMessages'); + } + if (isInsideTopic) { return lang('AccDescrTopic'); } @@ -332,7 +349,7 @@ const RightHeader: FC = ({ switch (renderingContentKey) { case HeaderContent.PollResults: - return

{lang('PollResults')}

; + return

{lang('PollResults')}

; case HeaderContent.Search: return ( <> @@ -354,39 +371,39 @@ const RightHeader: FC = ({ ); case HeaderContent.AddingMembers: - return

{lang(isChannel ? 'ChannelAddSubscribers' : 'GroupAddMembers')}

; + return

{lang(isChannel ? 'ChannelAddSubscribers' : 'GroupAddMembers')}

; case HeaderContent.ManageInitial: - return

{lang('Edit')}

; + return

{lang('Edit')}

; case HeaderContent.ManageChatPrivacyType: - return

{lang(isChannel ? 'ChannelTypeHeader' : 'GroupTypeHeader')}

; + return

{lang(isChannel ? 'ChannelTypeHeader' : 'GroupTypeHeader')}

; case HeaderContent.ManageDiscussion: - return

{lang('Discussion')}

; + return

{lang('Discussion')}

; case HeaderContent.ManageChatAdministrators: - return

{lang('ChannelAdministrators')}

; + return

{lang('ChannelAdministrators')}

; case HeaderContent.ManageGroupRecentActions: - return

{lang('Group.Info.AdminLog')}

; + return

{lang('Group.Info.AdminLog')}

; case HeaderContent.ManageGroupAdminRights: - return

{lang('EditAdminRights')}

; + return

{lang('EditAdminRights')}

; case HeaderContent.ManageGroupNewAdminRights: - return

{lang('SetAsAdmin')}

; + return

{lang('SetAsAdmin')}

; case HeaderContent.ManageGroupPermissions: - return

{lang('ChannelPermissions')}

; + return

{lang('ChannelPermissions')}

; case HeaderContent.ManageGroupRemovedUsers: - return

{lang('BlockedUsers')}

; + return

{lang('BlockedUsers')}

; case HeaderContent.ManageChannelRemovedUsers: - return

{lang('ChannelBlockedUsers')}

; + return

{lang('ChannelBlockedUsers')}

; case HeaderContent.ManageGroupUserPermissionsCreate: - return

{lang('ChannelAddException')}

; + return

{lang('ChannelAddException')}

; case HeaderContent.ManageGroupUserPermissions: - return

{lang('UserRestrictions')}

; + return

{lang('UserRestrictions')}

; case HeaderContent.ManageInvites: - return

{lang('lng_group_invite_title')}

; + return

{lang('lng_group_invite_title')}

; case HeaderContent.ManageEditInvite: - return

{isEditingInvite ? lang('EditLink') : lang('NewLink')}

; + return

{isEditingInvite ? lang('EditLink') : lang('NewLink')}

; case HeaderContent.ManageInviteInfo: return ( <> -

{lang('InviteLink')}

+

{lang('InviteLink')}

{currentInviteInfo && !currentInviteInfo.isRevoked && (