diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index f54a82473..bcd568728 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -158,7 +158,8 @@ export type UniversalMessage = ( & Pick, ( 'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' | 'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' | - 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards' | 'silent' | 'pinned' + 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards' | 'silent' | 'pinned' | + 'savedPeerId' )> ); @@ -197,6 +198,8 @@ export function buildApiMessageWithChatId( const emojiOnlyCount = getEmojiOnlyCountForMessage(content, groupedId); const hasComments = mtpMessage.replies?.comments; + const savedPeerId = mtpMessage.savedPeerId && getApiChatIdFromMtpPeer(mtpMessage.savedPeerId); + return omitUndefined({ id: mtpMessage.id, chatId, @@ -233,6 +236,7 @@ export function buildApiMessageWithChatId( isProtected, isForwardingAllowed, hasComments, + savedPeerId, } satisfies ApiMessage); } diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index e5e39c013..d31116b54 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -72,8 +72,10 @@ import { } from '../helpers'; import localDb from '../localDb'; import { scheduleMutedChatUpdate } from '../scheduleUnmute'; -import { applyState, processUpdate, updateChannelState } from '../updateManager'; -import { dispatchThreadInfoUpdates } from '../updater'; +import { + applyState, processAffectedHistory, processUpdate, updateChannelState, +} from '../updates/updateManager'; +import { dispatchThreadInfoUpdates } from '../updates/updater'; import { invokeRequest, uploadFile } from './client'; type FullChatData = { @@ -1787,7 +1789,7 @@ export async function fetchTopicById({ }; } -export function deleteTopic({ +export async function deleteTopic({ chat, topicId, }: { chat: ApiChat; @@ -1795,12 +1797,18 @@ export function deleteTopic({ }) { const { id, accessHash } = chat; - return invokeRequest(new GramJs.channels.DeleteTopicHistory({ + const result = await invokeRequest(new GramJs.channels.DeleteTopicHistory({ channel: buildInputPeer(id, accessHash), topMsgId: topicId, - }), { - shouldReturnTrue: true, - }); + })); + + if (!result) return; + + processAffectedHistory(chat, result); + + if (result.offset) { + await deleteTopic({ chat, topicId }); + } } export function togglePinnedTopic({ diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index e43e6fbc5..311779e1e 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -36,7 +36,7 @@ import { reset as resetUpdatesManager, scheduleGetChannelDifference, updateChannelState, -} from '../updateManager'; +} from '../updates/updateManager'; import { onAuthError, onAuthReady, onCurrentUserUpdate, onRequestCode, onRequestPassword, onRequestPhoneNumber, onRequestQrCode, onRequestRegistration, onWebAuthTokenFailed, diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 12b9498c8..9206476a4 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -35,6 +35,7 @@ export { reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs, saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, transcribeAudio, closePoll, fetchExtendedMedia, translateText, fetchMessageViews, fetchDiscussionMessage, clickSponsoredMessage, + deleteSavedHistory, } from './messages'; export { diff --git a/src/api/gramjs/methods/init.ts b/src/api/gramjs/methods/init.ts index 19199a69c..8cca9ccf1 100644 --- a/src/api/gramjs/methods/init.ts +++ b/src/api/gramjs/methods/init.ts @@ -10,7 +10,7 @@ import type { MethodArgs, MethodResponse, Methods } from './types'; import { API_THROTTLE_RESET_UPDATES, API_UPDATE_THROTTLE } from '../../../config'; import { throttle, throttleWithTickEnd } from '../../../util/schedulers'; import { updateFullLocalDb } from '../localDb'; -import { init as initUpdater } from '../updater'; +import { init as initUpdater } from '../updates/updater'; import { init as initAuth } from './auth'; import { init as initBots } from './bots'; import { init as initCalls } from './calls'; diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 08c73e562..184b3b098 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -80,8 +80,8 @@ import { addMessageToLocalDb, deserializeBytes, } from '../helpers'; -import { updateChannelState } from '../updateManager'; -import { dispatchThreadInfoUpdates } from '../updater'; +import { processAffectedHistory, updateChannelState } from '../updates/updateManager'; +import { dispatchThreadInfoUpdates } from '../updates/updater'; import { requestChatUpdate } from './chats'; import { handleGramJsUpdate, invokeRequest, uploadFile } from './client'; @@ -715,10 +715,18 @@ export async function pinMessage({ } export async function unpinAllMessages({ chat, threadId }: { chat: ApiChat; threadId?: ThreadId }) { - await invokeRequest(new GramJs.messages.UnpinAllMessages({ + const result = await invokeRequest(new GramJs.messages.UnpinAllMessages({ peer: buildInputPeer(chat.id, chat.accessHash), ...(threadId && { topMsgId: Number(threadId) }), })); + + if (!result) return; + + processAffectedHistory(chat, result); + + if (result.offset) { + await unpinAllMessages({ chat, threadId }); + } } export async function deleteMessages({ @@ -744,6 +752,8 @@ export async function deleteMessages({ return; } + processAffectedHistory(chat, result); + onUpdate({ '@type': 'deleteMessages', ids: messageIds, @@ -784,9 +794,13 @@ export async function deleteHistory({ return; } - if ('offset' in result && result.offset) { - await deleteHistory({ chat, shouldDeleteForAll }); - return; + if ('offset' in result) { + processAffectedHistory(chat, result); + + if (result.offset) { + await deleteHistory({ chat, shouldDeleteForAll }); + return; + } } onUpdate({ @@ -795,6 +809,32 @@ export async function deleteHistory({ }); } +export async function deleteSavedHistory({ + chat, +}: { + chat: ApiChat; +}) { + const result = await invokeRequest(new GramJs.messages.DeleteSavedHistory({ + peer: buildInputPeer(chat.id, chat.accessHash), + })); + + if (!result) { + return; + } + + processAffectedHistory(chat, result); + + if (result.offset) { + await deleteSavedHistory({ chat }); + return; + } + + onUpdate({ + '@type': 'deleteSavedHistory', + chatId: chat.id, + }); +} + export async function reportMessages({ peer, messageIds, reason, description, }: { @@ -862,10 +902,14 @@ export async function markMessageListRead({ readMaxId: fixedMaxId, })); } else { - await invokeRequest(new GramJs.messages.ReadHistory({ + const result = await invokeRequest(new GramJs.messages.ReadHistory({ peer: buildInputPeer(chat.id, chat.accessHash), maxId: fixedMaxId, })); + + if (result) { + processAffectedHistory(chat, result); + } } if (threadId === MAIN_THREAD_ID) { @@ -880,7 +924,7 @@ export async function markMessagesRead({ }) { const isChannel = getEntityTypeById(chat.id) === 'channel'; - await invokeRequest( + const result = await invokeRequest( isChannel ? new GramJs.channels.ReadMessageContents({ channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, @@ -891,6 +935,14 @@ export async function markMessagesRead({ }), ); + if (!result) { + return; + } + + if (result !== true) { + processAffectedHistory(chat, result); + } + onUpdate({ ...(isChannel ? { '@type': 'updateChannelMessages', @@ -1575,28 +1627,40 @@ export function clickSponsoredMessage({ chat, random }: { chat: ApiChat; random: })); } -export function readAllMentions({ +export async function readAllMentions({ chat, }: { chat: ApiChat; }) { - return invokeRequest(new GramJs.messages.ReadMentions({ + const result = await invokeRequest(new GramJs.messages.ReadMentions({ peer: buildInputPeer(chat.id, chat.accessHash), - }), { - shouldReturnTrue: true, - }); + })); + + if (!result) return; + + processAffectedHistory(chat, result); + + if (result.offset) { + await readAllMentions({ chat }); + } } -export function readAllReactions({ +export async function readAllReactions({ chat, }: { chat: ApiChat; }) { - return invokeRequest(new GramJs.messages.ReadReactions({ + const result = await invokeRequest(new GramJs.messages.ReadReactions({ peer: buildInputPeer(chat.id, chat.accessHash), - }), { - shouldReturnTrue: true, - }); + })); + + if (!result) return; + + processAffectedHistory(chat, result); + + if (result.offset) { + await readAllReactions({ chat }); + } } export async function fetchUnreadMentions({ diff --git a/src/api/gramjs/updates/UpdatePts.ts b/src/api/gramjs/updates/UpdatePts.ts new file mode 100644 index 000000000..63f42bb7f --- /dev/null +++ b/src/api/gramjs/updates/UpdatePts.ts @@ -0,0 +1,16 @@ +/* eslint-disable max-classes-per-file */ +import type { BigInteger } from 'big-integer'; + +export class LocalUpdatePts { + constructor(public pts: number, public ptsCount: number) {} +} + +export class LocalUpdateChannelPts { + constructor(public channelId: BigInteger, public pts: number, public ptsCount: number) {} +} + +export type UpdatePts = LocalUpdatePts | LocalUpdateChannelPts; + +export function buildLocalUpdatePts(pts: number, ptsCount: number, channelId?: BigInteger) { + return channelId ? new LocalUpdateChannelPts(channelId, pts, ptsCount) : new LocalUpdatePts(pts, ptsCount); +} diff --git a/src/api/gramjs/updateManager.ts b/src/api/gramjs/updates/updateManager.ts similarity index 90% rename from src/api/gramjs/updateManager.ts rename to src/api/gramjs/updates/updateManager.ts index fe27e89d7..8589932e1 100644 --- a/src/api/gramjs/updateManager.ts +++ b/src/api/gramjs/updates/updateManager.ts @@ -1,17 +1,20 @@ -import { Api as GramJs } from '../../lib/gramjs'; -import { UpdateConnectionState, UpdateServerTimeOffset } from '../../lib/gramjs/network'; +import { Api as GramJs } from '../../../lib/gramjs'; +import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network'; -import type { invokeRequest } from './methods/client'; +import type { ApiChat } from '../../types'; +import type { invokeRequest } from '../methods/client'; import type { Update } from './updater'; -import { DEBUG } from '../../config'; -import SortedQueue from '../../util/SortedQueue'; -import { buildApiPeerId } from './apiBuilders/peers'; -import { buildInputEntity } from './gramjsBuilders'; -import { addEntitiesToLocalDb } from './helpers'; -import localDb from './localDb'; +import { DEBUG } from '../../../config'; +import SortedQueue from '../../../util/SortedQueue'; +import { buildApiPeerId } from '../apiBuilders/peers'; +import { buildInputEntity, buildMtpPeerId } from '../gramjsBuilders'; +import { addEntitiesToLocalDb } from '../helpers'; +import localDb from '../localDb'; import { dispatchUserAndChatUpdates, sendUpdate, updater } from './updater'; +import { buildLocalUpdatePts, type UpdatePts } from './UpdatePts'; + export type State = { seq: number; date: number; @@ -19,7 +22,7 @@ export type State = { qts: number; }; type SeqUpdate = (GramJs.Updates | GramJs.UpdatesCombined) & { _isFromDifference?: true }; -type PtsUpdate = GramJs.TypeUpdate & { pts: number } & { _isFromDifference?: true }; +type PtsUpdate = ((GramJs.TypeUpdate & { pts: number }) | UpdatePts) & { _isFromDifference?: true }; const COMMON_BOX_QUEUE_ID = '0'; const CHANNEL_DIFFERENCE_LIMIT = 1000; @@ -201,10 +204,12 @@ function popPtsQueue(channelId: string) { const pts = update.pts; const ptsCount = getPtsCount(update); + // Sometimes server sends updates for channels that are opened in other clients. We ignore them if (localPts === undefined) { if (DEBUG) { + // Uncomment to debug missing updates // eslint-disable-next-line no-console - console.error('[UpdateManager] Got pts update without local state', channelId); + // console.error('[UpdateManager] Got pts update without local state', channelId); } return; } @@ -389,6 +394,16 @@ export function reset() { isInited = false; } +export function processAffectedHistory( + chat: ApiChat, affected: GramJs.messages.AffectedMessages | GramJs.messages.AffectedHistory, +) { + const isChannel = chat.type === 'chatTypeChannel' || chat.type === 'chatTypeSuperGroup'; + const channeId = isChannel ? buildMtpPeerId(chat.id, 'channel') : undefined; + const update = buildLocalUpdatePts(affected.pts, affected.ptsCount, channeId); + + processUpdate(update); +} + async function loadRemoteState() { const remoteState = await invoke(new GramJs.updates.GetState()); if (!remoteState) return; diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updates/updater.ts similarity index 96% rename from src/api/gramjs/updater.ts rename to src/api/gramjs/updates/updater.ts index c1b7b7751..7cfd75da0 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updates/updater.ts @@ -1,21 +1,21 @@ -import { Api as GramJs, connection } from '../../lib/gramjs'; +import { Api as GramJs, connection } from '../../../lib/gramjs'; -import type { GroupCallConnectionData } from '../../lib/secret-sauce'; +import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; import type { ApiMessage, ApiMessageExtendedMediaPreview, ApiStory, ApiStorySkipped, ApiUpdate, ApiUpdateConnectionStateType, MediaContent, OnApiUpdate, -} from '../types'; +} from '../../types'; -import { DEBUG, GENERAL_TOPIC_ID } from '../../config'; -import { compact, omit, pick } from '../../util/iteratees'; -import { getServerTimeOffset, setServerTimeOffset } from '../../util/serverTime'; -import { buildApiBotMenuButton } from './apiBuilders/bots'; +import { DEBUG, GENERAL_TOPIC_ID } from '../../../config'; +import { compact, omit, pick } from '../../../util/iteratees'; +import { getServerTimeOffset, setServerTimeOffset } from '../../../util/serverTime'; +import { buildApiBotMenuButton } from '../apiBuilders/bots'; import { buildApiGroupCall, buildApiGroupCallParticipant, buildPhoneCall, getGroupCallId, -} from './apiBuilders/calls'; +} from '../apiBuilders/calls'; import { buildApiChatFolder, buildApiChatFromPreview, @@ -24,15 +24,15 @@ import { buildChatMember, buildChatMembers, buildChatTypingStatus, -} from './apiBuilders/chats'; -import { buildApiPhoto, buildApiUsernames, buildPrivacyRules } from './apiBuilders/common'; -import { omitVirtualClassFields } from './apiBuilders/helpers'; +} from '../apiBuilders/chats'; +import { buildApiPhoto, buildApiUsernames, buildPrivacyRules } from '../apiBuilders/common'; +import { omitVirtualClassFields } from '../apiBuilders/helpers'; import { buildApiMessageExtendedMediaPreview, buildMessageMediaContent, buildPoll, buildPollResults, -} from './apiBuilders/messageContent'; +} from '../apiBuilders/messageContent'; import { buildApiMessage, buildApiMessageFromNotification, @@ -40,28 +40,28 @@ import { buildApiMessageFromShortChat, buildApiThreadInfoFromMessage, buildMessageDraft, -} from './apiBuilders/messages'; +} from '../apiBuilders/messages'; import { buildApiNotifyException, buildApiNotifyExceptionTopic, buildPrivacyKey, -} from './apiBuilders/misc'; -import { buildApiEmojiStatus, buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; +} from '../apiBuilders/misc'; +import { buildApiEmojiStatus, buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { buildApiReaction, buildMessageReactions, -} from './apiBuilders/reactions'; -import { buildApiStealthMode, buildApiStory } from './apiBuilders/stories'; -import { buildApiEmojiInteraction, buildStickerSet } from './apiBuilders/symbols'; +} from '../apiBuilders/reactions'; +import { buildApiStealthMode, buildApiStory } from '../apiBuilders/stories'; +import { buildApiEmojiInteraction, buildStickerSet } from '../apiBuilders/symbols'; import { buildApiUser, buildApiUserStatus, -} from './apiBuilders/users'; +} from '../apiBuilders/users'; import { buildChatPhotoForLocalDb, buildMessageFromUpdate, isMessageWithMedia, -} from './gramjsBuilders'; +} from '../gramjsBuilders'; import { addEntitiesToLocalDb, addMessageToLocalDb, @@ -72,13 +72,15 @@ import { resolveMessageApiChatId, serializeBytes, swapLocalInvoiceMedia, -} from './helpers'; -import localDb from './localDb'; -import { scheduleMutedChatUpdate, scheduleMutedTopicUpdate } from './scheduleUnmute'; +} from '../helpers'; +import localDb from '../localDb'; +import { scheduleMutedChatUpdate, scheduleMutedTopicUpdate } from '../scheduleUnmute'; + +import { LocalUpdateChannelPts, LocalUpdatePts, type UpdatePts } from './UpdatePts'; export type Update = ( (GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] } -) | typeof connection.UpdateConnectionState; +) | typeof connection.UpdateConnectionState | UpdatePts; const DELETE_MISSING_CHANNEL_MESSAGE_DELAY = 1000; @@ -1156,6 +1158,8 @@ export function updater(update: Update) { chatId: buildApiPeerId(update.channelId, 'channel'), isEnabled: update.enabled ? true : undefined, }); + } else if (update instanceof LocalUpdatePts || update instanceof LocalUpdateChannelPts) { + // Do nothing, handled on the manager side } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; log('UNEXPECTED UPDATE', params); diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index a303b279b..3e2950e23 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -536,6 +536,7 @@ export interface ApiMessage { }; reactions?: ApiReactions; hasComments?: boolean; + savedPeerId?: string; } export interface ApiReactions { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 0934e901d..65428aa18 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -315,6 +315,11 @@ export type ApiUpdateDeleteHistory = { chatId: string; }; +export type ApiUpdateDeleteSavedHistory = { + '@type': 'deleteSavedHistory'; + chatId: string; +}; + export type ApiUpdateDeleteProfilePhotos = { '@type': 'deleteProfilePhotos'; ids: string[]; @@ -730,7 +735,8 @@ export type ApiUpdate = ( ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory | ApiUpdateSentStoryReaction | ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages | ApiUpdateStealthMode | ApiUpdateAttachMenuBots | ApiUpdateNewAuthorization | ApiUpdateGroupInvitePrivacyForbidden | - ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage + ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage | + ApiUpdateDeleteSavedHistory ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/components/common/DeleteChatModal.tsx b/src/components/common/DeleteChatModal.tsx index 8d55f0ff1..30bd401b8 100644 --- a/src/components/common/DeleteChatModal.tsx +++ b/src/components/common/DeleteChatModal.tsx @@ -28,6 +28,7 @@ import './DeleteChatModal.scss'; export type OwnProps = { isOpen: boolean; chat: ApiChat; + isSavedDialog?: boolean; onClose: () => void; onCloseAnimationEnd?: () => void; }; @@ -47,6 +48,7 @@ type StateProps = { const DeleteChatModal: FC = ({ isOpen, chat, + isSavedDialog, isChannel, isPrivateChat, isChatWithSelf, @@ -62,6 +64,7 @@ const DeleteChatModal: FC = ({ const { leaveChannel, deleteHistory, + deleteSavedHistory, deleteChannel, deleteChatUser, blockUser, @@ -74,7 +77,7 @@ const DeleteChatModal: FC = ({ deleteHistory({ chatId: chat.id, shouldDeleteForAll: true }); onClose(); - }, [deleteHistory, chat.id, onClose]); + }, [chat.id, onClose]); const handleDeleteAndStop = useCallback(() => { deleteHistory({ chatId: chat.id, shouldDeleteForAll: true }); @@ -84,7 +87,9 @@ const DeleteChatModal: FC = ({ }, [chat.id, onClose]); const handleDeleteChat = useCallback(() => { - if (isPrivateChat) { + if (isSavedDialog) { + deleteSavedHistory({ chatId: chat.id }); + } else if (isPrivateChat) { deleteHistory({ chatId: chat.id, shouldDeleteForAll: false }); } else if (isBasicGroup) { deleteChatUser({ chatId: chat.id, userId: currentUserId! }); @@ -103,11 +108,8 @@ const DeleteChatModal: FC = ({ currentUserId, chat.isCreator, chat.id, + isSavedDialog, onClose, - deleteHistory, - deleteChatUser, - leaveChannel, - deleteChannel, ]); const handleLeaveChat = useCallback(() => { @@ -133,6 +135,10 @@ const DeleteChatModal: FC = ({ } function renderTitle() { + if (isSavedDialog) { + return isChatWithSelf ? 'ClearHistoryMyNotesTitle' : 'ClearHistoryTitleSingle2'; + } + if (isChannel && !chat.isCreator) { return 'LeaveChannel'; } @@ -149,6 +155,16 @@ const DeleteChatModal: FC = ({ } function renderContent() { + if (isSavedDialog) { + return ( +

+ {renderText( + isChatWithSelf ? lang('ClearHistoryMyNotesMessage') : lang('ClearHistoryMessageSingle', chatTitle), + ['simple_markdown', 'emoji'], + )} +

+ ); + } if (isChannel && chat.isCreator) { return (

@@ -165,6 +181,10 @@ const DeleteChatModal: FC = ({ } function renderActionText() { + if (isSavedDialog) { + return 'Delete'; + } + if (isChannel && !chat.isCreator) { return 'LeaveChannel'; } @@ -189,7 +209,7 @@ const DeleteChatModal: FC = ({ > {renderContent()}

- {isBot && ( + {isBot && !isSavedDialog && ( @@ -199,7 +219,7 @@ const DeleteChatModal: FC = ({ {contactName ? renderText(lang('ChatList.DeleteForEveryone', contactName)) : lang('DeleteForAll')} )} - {!isPrivateChat && chat.isCreator && ( + {!isPrivateChat && chat.isCreator && !isSavedDialog && ( @@ -208,7 +228,7 @@ const DeleteChatModal: FC = ({ color="danger" className="confirm-dialog-button" isText - onClick={isPrivateChat ? handleDeleteChat : handleLeaveChat} + onClick={(isPrivateChat || isSavedDialog) ? handleDeleteChat : handleLeaveChat} > {lang(renderActionText())} @@ -219,12 +239,12 @@ const DeleteChatModal: FC = ({ }; export default memo(withGlobal( - (global, { chat }): StateProps => { + (global, { chat, isSavedDialog }): StateProps => { const isPrivateChat = isUserId(chat.id); const isChatWithSelf = selectIsChatWithSelf(global, chat.id); const user = isPrivateChat && selectUser(global, getPrivateChatUserId(chat)!); const isBot = user && isUserBot(user) && !chat.isSupport; - const canDeleteForAll = (isPrivateChat && !isChatWithSelf && !isBot); + const canDeleteForAll = (isPrivateChat && !isChatWithSelf && !isBot && !isSavedDialog); const contactName = isPrivateChat ? getUserFirstOrLastName(selectUser(global, getPrivateChatUserId(chat)!)) : undefined; diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 859c66481..276dd8987 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -354,6 +354,7 @@ const Chat: FC = ({ onClose={closeDeleteModal} onCloseAnimationEnd={unmarkRenderDeleteModal} chat={chat} + isSavedDialog={isSavedDialog} /> )} {shouldRenderMuteModal && ( diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index 0891e3dd9..af7057877 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -12,6 +12,7 @@ import { ManagementScreens } from '../../types'; import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom'; import { getHasAdminRight, + getIsSavedDialog, isAnonymousForwardsChat, isChatBasicGroup, isChatChannel, isChatSuperGroup, isUserId, } from '../../global/helpers'; @@ -478,6 +479,8 @@ export default memo(withGlobal( const isDiscussionThread = messageListType === 'thread' && threadId !== MAIN_THREAD_ID; const isRightColumnShown = selectIsRightColumnShown(global, isMobile); + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + const isUserBlocked = isPrivate ? selectIsUserBlocked(global, chatId) : false; const canRestartBot = Boolean(bot && isUserBlocked); const canStartBot = !canRestartBot && Boolean(selectIsChatBotNotStarted(global, chatId)); @@ -489,7 +492,7 @@ export default memo(withGlobal( 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 canLeave = isSavedDialog || (isMainThread && !canSubscribe); const canEnterVoiceChat = ARE_CALLS_SUPPORTED && isMainThread && chat.isCallActive; const canCreateVoiceChat = ARE_CALLS_SUPPORTED && isMainThread && !chat.isCallActive && (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat))); diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index d2eb8b2e9..3c003007b 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -15,6 +15,7 @@ import { getCanDeleteChat, getCanManageTopic, getHasAdminRight, + getIsSavedDialog, isChatChannel, isChatGroup, isUserId, @@ -119,6 +120,7 @@ type StateProps = { isBlocked?: boolean; isBot?: boolean; isChatWithSelf?: boolean; + savedDialog?: ApiChat; }; const CLOSE_MENU_ANIMATION_DURATION = 200; @@ -163,6 +165,7 @@ const HeaderMenuContainer: FC = ({ isBlocked, isBot, isChatWithSelf, + savedDialog, onJoinRequestsClick, onSubscribeChannel, onSearchClick, @@ -403,6 +406,24 @@ const HeaderMenuContainer: FC = ({ }); }, [botCommands, closeMenu, lang, sendBotCommand]); + const deleteTitle = useMemo(() => { + if (!chat) return undefined; + + if (isPrivate || savedDialog) { + return lang('DeleteChatUser'); + } + + if (canDeleteChat) { + return lang('GroupInfo.DeleteAndExit'); + } + + if (isChannel) { + return lang('LeaveChannel'); + } + + return lang('Group.LeaveGroup'); + }, [canDeleteChat, chat, isChannel, isPrivate, savedDialog, lang]); + return (
@@ -627,9 +648,7 @@ const HeaderMenuContainer: FC = ({ icon="delete" onClick={handleDelete} > - {lang(isPrivate - ? 'DeleteChatUser' - : (canDeleteChat ? 'GroupInfo.DeleteAndExit' : (isChannel ? 'LeaveChannel' : 'Group.LeaveGroup')))} + {deleteTitle} )} @@ -638,7 +657,8 @@ const HeaderMenuContainer: FC = ({ )} {canMute && shouldRenderMuteModal && chat?.id && ( @@ -694,6 +714,9 @@ export default memo(withGlobal( // Context menu item should only be displayed if user hid translation panel const canTranslate = selectCanTranslateChat(global, chatId) && fullInfo?.isTranslationDisabled; + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + const savedDialog = isSavedDialog ? selectChat(global, String(threadId)) : undefined; + return { chat, isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)), @@ -717,6 +740,7 @@ export default memo(withGlobal( isBlocked: userFullInfo?.isBlocked, isBot: Boolean(chatBot), isChatWithSelf, + savedDialog, }; }, )(HeaderMenuContainer)); diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index aa70b93c0..9485bee9a 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -583,7 +583,6 @@ addActionHandler('loadTopChats', (): ActionReturnType => { runThrottledForLoadTopChats(() => { loadChats('active'); loadChats('archived'); - loadChats('saved'); }); }); @@ -2244,9 +2243,7 @@ addActionHandler('deleteTopic', async (global, actions, payload): Promise const chat = selectChat(global, chatId); if (!chat) return; - const result = await callApi('deleteTopic', { chat, topicId }); - - if (!result) return; + await callApi('deleteTopic', { chat, topicId }); global = getGlobal(); global = deleteTopic(global, chatId, topicId); diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index ce73161ac..f1673e65a 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -710,6 +710,22 @@ addActionHandler('deleteHistory', async (global, actions, payload): Promise => { + const { chatId, tabId = getCurrentTabId() } = payload!; + const chat = selectChat(global, chatId); + if (!chat) { + return; + } + + await callApi('deleteSavedHistory', { chat }); + + global = getGlobal(); + const activeChat = selectCurrentMessageList(global, tabId); + if (activeChat && activeChat.threadId === chatId) { + actions.openChat({ id: undefined, tabId }); + } +}); + addActionHandler('reportMessages', async (global, actions, payload): Promise => { const { messageIds, reason, description, tabId = getCurrentTabId(), diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 79d77044b..aa03bbc74 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -464,6 +464,18 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { break; } + case 'deleteSavedHistory': { + const { chatId } = update; + const currentUserId = global.currentUserId!; + global = removeChatFromChatLists(global, chatId, 'saved'); + setGlobal(global); + + global = getGlobal(); + deleteThread(global, currentUserId, chatId, actions); + + break; + } + case 'updateCommonBoxMessages': { const { ids, messageUpdate } = update; @@ -925,6 +937,29 @@ function findLastMessage(global: T, chatId: string, threa return undefined; } +export function deleteThread( + global: T, + chatId: string, + threadId: ThreadId, + actions: RequiredGlobalActions, +) { + const byId = selectChatMessages(global, chatId); + if (!byId) { + return; + } + + const messageIds = Object.values(byId).filter((message) => { + const messageThreadId = selectThreadIdFromMessage(global, message); + return messageThreadId === threadId; + }).map((message) => message.id); + + if (!messageIds.length) { + return; + } + + deleteMessages(global, chatId, messageIds, actions); +} + export function deleteMessages( global: T, chatId: string | undefined, ids: number[], actions: RequiredGlobalActions, ) { @@ -942,8 +977,6 @@ export function deleteMessages( isDeleting: true, }); - global = clearMessageTranslation(global, chatId, id); - if (chat.topics?.[id]) { global = deleteTopic(global, chatId, id); } diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index 5a36aa8ce..2f184f40f 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -36,6 +36,7 @@ import { selectViewportIds, } from '../selectors'; import { updateTabState } from './tabs'; +import { clearMessageTranslation } from './translations'; type MessageStoreSections = { byId: Record; @@ -261,10 +262,12 @@ export function deleteChatMessages( const message = byId[messageId]; if (!message) return; const threadId = selectThreadIdFromMessage(global, message); - if (!threadId || threadId === MAIN_THREAD_ID) return; + if (!threadId) return; const threadMessages = updatedThreads.get(threadId) || []; threadMessages.push(messageId); updatedThreads.set(threadId, threadMessages); + + global = clearMessageTranslation(global, chatId, messageId); }); const deletedForwardedPosts = Object.values(pickTruthy(byId, messageIds)).filter( @@ -297,15 +300,11 @@ export function deleteChatMessages( } Object.values(global.byTabId).forEach(({ id: tabId }) => { - let viewportIds = selectViewportIds(global, chatId, threadId, tabId); + const viewportIds = selectViewportIds(global, chatId, threadId, tabId); + if (!viewportIds) return; - messageIds.forEach((messageId) => { - if (viewportIds?.includes(messageId)) { - viewportIds = viewportIds.filter((id) => id !== messageId); - } - }); - - global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', viewportIds, tabId); + const newViewportIds = excludeSortedArray(viewportIds, messageIds); + global = replaceTabThreadParam(global, chatId, threadId, 'viewportIds', newViewportIds, tabId); }); global = replaceThreadParam(global, chatId, threadId, 'listedIds', listedIds); diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index ece515db9..d7a405ed9 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -508,7 +508,12 @@ export function selectCanDeleteTopic(global: T, chatId: s export function selectSavedDialogIdFromMessage( global: T, message: ApiMessage, ): string | undefined { - const { chatId, senderId, forwardInfo } = message; + const { + chatId, senderId, forwardInfo, savedPeerId, + } = message; + + if (savedPeerId) return savedPeerId; + if (chatId !== global.currentUserId) { return undefined; } diff --git a/src/global/types.ts b/src/global/types.ts index 53b9062a8..67ae69797 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -1385,6 +1385,9 @@ export interface ActionPayloads { chatId: string; shouldDeleteForAll?: boolean; } & WithTabId; + deleteSavedHistory: { + chatId: string; + } & WithTabId; loadSponsoredMessages: { chatId: string; }; diff --git a/src/hooks/useChatContextActions.ts b/src/hooks/useChatContextActions.ts index 1ac466b7d..b01a003ec 100644 --- a/src/hooks/useChatContextActions.ts +++ b/src/hooks/useChatContextActions.ts @@ -45,6 +45,24 @@ const useChatContextActions = ({ const { isSelf } = user || {}; const isServiceNotifications = user?.id === SERVICE_NOTIFICATIONS_USER_ID; + const deleteTitle = useMemo(() => { + if (!chat) return undefined; + + if (isUserId(chat.id) || isSavedDialog) { + return lang('DeleteChatUser'); + } + + if (getCanDeleteChat(chat)) { + return lang('DeleteChat'); + } + + if (isChatChannel(chat)) { + return lang('LeaveChannel'); + } + + return lang('Group.LeaveGroup'); + }, [chat, isSavedDialog, lang]); + return useMemo(() => { if (!chat) { return undefined; @@ -91,8 +109,15 @@ const useChatContextActions = ({ handler: togglePinned, }; + const actionDelete = { + title: deleteTitle, + icon: 'delete', + destructive: true, + handler: handleDelete, + }; + if (isSavedDialog) { - return compact([actionOpenInNewTab, actionPin]) as MenuItemContextAction[]; + return compact([actionOpenInNewTab, actionPin, actionDelete]) as MenuItemContextAction[]; } const actionAddToFolder = canChangeFolder ? { @@ -133,17 +158,6 @@ const useChatContextActions = ({ ? { title: lang('ReportPeer.Report'), icon: 'flag', handler: handleReport } : undefined; - const actionDelete = { - title: isUserId(chat.id) - ? lang('Delete') - : lang(getCanDeleteChat(chat) - ? 'DeleteChat' - : (isChatChannel(chat) ? 'LeaveChannel' : 'Group.LeaveGroup')), - icon: 'delete', - destructive: true, - handler: handleDelete, - }; - const isInFolder = folderId !== undefined; return compact([ @@ -159,7 +173,7 @@ const useChatContextActions = ({ ]) as MenuItemContextAction[]; }, [ chat, user, canChangeFolder, lang, handleChatFolderChange, isPinned, isInSearch, isMuted, currentUserId, - handleDelete, handleMute, handleReport, folderId, isSelf, isServiceNotifications, isSavedDialog, + handleDelete, handleMute, handleReport, folderId, isSelf, isServiceNotifications, isSavedDialog, deleteTitle, ]); }; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 4d5c17e88..f68bcfb21 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1404,6 +1404,7 @@ messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; messages.requestAppWebView#8c5a3b3c flags:# write_allowed:flags.0?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = AppWebViewResult; messages.getSavedDialogs#5381d21a flags:# exclude_pinned:flags.0?true offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; messages.getSavedHistory#3d9a414d peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#6e98102b flags:# peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; updates.getState#edd4882a = updates.State; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 17d181620..f8fbe5a2c 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -284,6 +284,7 @@ "messages.getUnreadMentions", "messages.getSavedDialogs", "messages.getSavedHistory", + "messages.deleteSavedHistory", "messages.getPinnedSavedDialogs", "messages.toggleSavedDialogPin", "help.getPremiumPromo",