diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 2a40cfbf9..afae50dc0 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -54,7 +54,7 @@ export { export { updateProfile, checkUsername, updateUsername, fetchBlockedContacts, blockContact, unblockContact, - updateProfilePhoto, uploadProfilePhoto, fetchWallpapers, uploadWallpaper, + updateProfilePhoto, uploadProfilePhoto, deleteProfilePhoto, fetchWallpapers, uploadWallpaper, fetchAuthorizations, terminateAuthorization, terminateAllAuthorizations, fetchWebAuthorizations, terminateWebAuthorization, terminateAllWebAuthorizations, fetchNotificationExceptions, fetchNotificationSettings, updateContactSignUpNotification, updateNotificationSettings, diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index 9bfbd16ec..8bf964efa 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -6,7 +6,7 @@ import type { ApiError, ApiLangString, ApiLanguage, - ApiNotifyException, + ApiNotifyException, ApiPhoto, } from '../../types'; import type { ApiPrivacyKey, InputPrivacyRules, LangCode } from '../../../types'; @@ -26,7 +26,9 @@ import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { buildAppConfig } from '../apiBuilders/appConfig'; import { omitVirtualClassFields } from '../apiBuilders/helpers'; -import { buildInputEntity, buildInputPeer, buildInputPrivacyKey } from '../gramjsBuilders'; +import { + buildInputEntity, buildInputPeer, buildInputPrivacyKey, buildInputPhoto, +} from '../gramjsBuilders'; import { getClient, invokeRequest, uploadFile } from './client'; import { buildCollectionByKey } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; @@ -79,6 +81,16 @@ export async function uploadProfilePhoto(file: File) { })); } +export async function deleteProfilePhoto(photo: ApiPhoto) { + const photoId = buildInputPhoto(photo); + if (!photoId) return false; + const isDeleted = await invokeRequest(new GramJs.photos.DeletePhotos({ id: [photoId] }), true); + if (isDeleted) { + delete localDb.photos[photo.id]; + } + return isDeleted; +} + export async function fetchWallpapers() { const result = await invokeRequest(new GramJs.account.GetWallPapers({ hash: BigInt('0') })); diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index b20504d62..04b62de0d 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -127,7 +127,7 @@ const Avatar: FC = ({ const userId = user?.id; useEffect(() => { - if (shouldShowVideo && !profilePhoto) { + if (userId && shouldShowVideo && !profilePhoto) { loadFullUser({ userId }); } }, [loadFullUser, profilePhoto, userId, shouldShowVideo]); diff --git a/src/components/common/DeleteMessageModal.tsx b/src/components/common/DeleteMessageModal.tsx index 3463df8ed..c455bf07b 100644 --- a/src/components/common/DeleteMessageModal.tsx +++ b/src/components/common/DeleteMessageModal.tsx @@ -29,7 +29,8 @@ export type OwnProps = { isSchedule: boolean; message: ApiMessage; album?: IAlbum; - onClose: () => void; + onClose: NoneToVoidFunction; + onConfirm?: NoneToVoidFunction; }; type StateProps = { @@ -48,6 +49,7 @@ const DeleteMessageModal: FC = ({ contactName, willDeleteForCurrentUserOnly, willDeleteForAll, + onConfirm, onClose, }) => { const { @@ -56,14 +58,16 @@ const DeleteMessageModal: FC = ({ } = getActions(); const handleDeleteMessageForAll = useCallback(() => { + onConfirm?.(); const messageIds = album?.messages ? album.messages.map(({ id }) => id) : [message.id]; deleteMessages({ messageIds, shouldDeleteForAll: true }); onClose(); - }, [deleteMessages, message.id, onClose, album]); + }, [onConfirm, album, message.id, deleteMessages, onClose]); const handleDeleteMessageForSelf = useCallback(() => { + onConfirm?.(); const messageIds = album?.messages ? album.messages.map(({ id }) => id) : [message.id]; @@ -76,7 +80,7 @@ const DeleteMessageModal: FC = ({ }); } onClose(); - }, [album, message.id, isSchedule, onClose, deleteScheduledMessages, deleteMessages]); + }, [onConfirm, album, message.id, isSchedule, onClose, deleteScheduledMessages, deleteMessages]); const lang = useLang(); diff --git a/src/components/common/DeleteProfilePhotoModal.tsx b/src/components/common/DeleteProfilePhotoModal.tsx new file mode 100644 index 000000000..b253ae82f --- /dev/null +++ b/src/components/common/DeleteProfilePhotoModal.tsx @@ -0,0 +1,54 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { useCallback, memo } from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { ApiPhoto } from '../../api/types'; +import useLang from '../../hooks/useLang'; + +import Modal from '../ui/Modal'; +import Button from '../ui/Button'; + +export type OwnProps = { + isOpen: boolean; + photo: ApiPhoto; + profileId: string; + onConfirm?: NoneToVoidFunction; + onClose: NoneToVoidFunction; +}; + +const DeleteProfilePhotoModal: FC = ({ + isOpen, + photo, + profileId, + onClose, + onConfirm, +}) => { + const { + deleteProfilePhoto, + } = getActions(); + + const handleDeletePhoto = useCallback(() => { + onConfirm?.(); + deleteProfilePhoto({ photo, profileId }); + onClose(); + }, [onConfirm, deleteProfilePhoto, photo, profileId, onClose]); + + const lang = useLang(); + + return ( + + + + + ); +}; + +export default memo(DeleteProfilePhotoModal); diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 995682319..14a38fbbd 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -59,6 +59,7 @@ type StateProps = { mediaId?: number; senderId?: string; isChatWithSelf?: boolean; + canDeleteMedia?: boolean; origin?: MediaViewerOrigin; avatarOwner?: ApiChat | ApiUser; message?: ApiMessage; @@ -77,6 +78,7 @@ const MediaViewer: FC = ({ mediaId, senderId, isChatWithSelf, + canDeleteMedia, origin, avatarOwner, message, @@ -145,6 +147,12 @@ const MediaViewer: FC = ({ animationKey.current = selectedMediaIndex; } + useEffect(() => { + if (isOpen && !mediaIds.length) { + closeMediaViewer(); + } + }, [isOpen, closeMediaViewer, mediaIds.length]); + useEffect(() => { if (!isOpen) { return undefined; @@ -279,12 +287,23 @@ const MediaViewer: FC = ({ return undefined; }, [mediaIds]); + const handleBeforeDelete = useCallback(() => { + if (mediaIds.length <= 1) { + handleClose(); + return; + } + let index = mediaId ? mediaIds.indexOf(mediaId) : -1; + // Before deleting, select previous media or the first one + index = index > 0 ? index - 1 : 0; + selectMedia(mediaIds[index]); + }, [handleClose, mediaId, mediaIds, selectMedia]); + const lang = useLang(); function renderSenderInfo() { return avatarOwner ? ( @@ -324,8 +343,12 @@ const MediaViewer: FC = ({ mediaData={fullMediaBlobUrl || previewBlobUrl} isVideo={isVideo} message={message} + canDeleteAvatar={canDeleteMedia && !!avatarPhoto} + avatarPhoto={avatarPhoto} + avatarOwnerId={avatarOwner?.id} fileName={fileName} canReport={canReport} + onBeforeDelete={handleBeforeDelete} onReport={openReportModal} onCloseMediaViewer={handleClose} onForward={handleForward} @@ -377,8 +400,7 @@ export default memo(withGlobal( animationLevel, } = global.settings.byKey; - const { shouldSkipHistoryAnimations } = global; - + const { shouldSkipHistoryAnimations, currentUserId } = global; let isChatWithSelf = !!chatId && selectIsChatWithSelf(global, chatId); if (origin === MediaViewerOrigin.SearchResult) { @@ -405,14 +427,20 @@ export default memo(withGlobal( } if (avatarOwnerId) { - const sender = selectUser(global, avatarOwnerId) || selectChat(global, avatarOwnerId); + const user = selectUser(global, avatarOwnerId); + const chat = selectChat(global, avatarOwnerId); + let canDeleteMedia = false; + if (user) canDeleteMedia = avatarOwnerId === currentUserId; + // TODO Support deleting chat photos + // if (chat) canDeleteMedia = isChatAdmin(chat); isChatWithSelf = selectIsChatWithSelf(global, avatarOwnerId); return { mediaId, senderId: avatarOwnerId, - avatarOwner: sender, + avatarOwner: user || chat, isChatWithSelf, + canDeleteMedia, animationLevel, origin, shouldSkipHistoryAnimations, diff --git a/src/components/mediaViewer/MediaViewerActions.tsx b/src/components/mediaViewer/MediaViewerActions.tsx index b53721725..55c0badf5 100644 --- a/src/components/mediaViewer/MediaViewerActions.tsx +++ b/src/components/mediaViewer/MediaViewerActions.tsx @@ -6,7 +6,9 @@ import React, { import { getActions, withGlobal } from '../../global'; import type { FC } from '../../lib/teact/teact'; -import type { ApiMessage } from '../../api/types'; +import type { + ApiMessage, ApiPhoto, +} from '../../api/types'; import type { MessageListType } from '../../global/types'; import type { MenuItemProps } from '../ui/MenuItem'; @@ -29,6 +31,7 @@ import DropdownMenu from '../ui/DropdownMenu'; import MenuItem from '../ui/MenuItem'; import ProgressSpinner from '../ui/ProgressSpinner'; import DeleteMessageModal from '../common/DeleteMessageModal'; +import DeleteProfilePhotoModal from '../common/DeleteProfilePhotoModal'; import './MediaViewerActions.scss'; @@ -45,9 +48,13 @@ type OwnProps = { isVideo: boolean; zoomLevelChange: number; message?: ApiMessage; + canDeleteAvatar?: boolean; + avatarPhoto?: ApiPhoto; + avatarOwnerId?: string; fileName?: string; canReport?: boolean; onReport: NoneToVoidFunction; + onBeforeDelete: NoneToVoidFunction; onCloseMediaViewer: NoneToVoidFunction; onForward: NoneToVoidFunction; setZoomLevelChange: (change: number) => void; @@ -57,6 +64,8 @@ const MediaViewerActions: FC = ({ mediaData, isVideo, message, + avatarPhoto, + avatarOwnerId, fileName, isChatProtected, isDownloading, @@ -67,6 +76,7 @@ const MediaViewerActions: FC = ({ messageListType, onReport, onCloseMediaViewer, + onBeforeDelete, onForward, setZoomLevelChange, }) => { @@ -118,6 +128,28 @@ const MediaViewerActions: FC = ({ ); }, []); + function renderDeleteModals() { + return message + ? ( + + ) + : (avatarOwnerId && avatarPhoto) ? ( + + ) : undefined; + } + function renderDownloadButton() { if (isProtected) { return undefined; @@ -218,14 +250,7 @@ const MediaViewerActions: FC = ({ ))} {isDownloading && } - {message && canDelete && ( - - )} + {canDelete && renderDeleteModals()} ); } @@ -293,26 +318,21 @@ const MediaViewerActions: FC = ({ > - {message && canDelete && ( - - )} + {canDelete && renderDeleteModals()} ); }; export default memo(withGlobal( - (global, { message }): StateProps => { + (global, { message, canDeleteAvatar }): StateProps => { const currentMessageList = selectCurrentMessageList(global); const { threadId } = selectCurrentMessageList(global) || {}; const isDownloading = message ? selectIsDownloading(global, message) : false; const isProtected = selectIsMessageProtected(global, message); const isChatProtected = message && selectIsChatProtected(global, message?.chatId); - const { canDelete } = (threadId && message && selectAllowedMessageActions(global, message, threadId)) || {}; + const { canDelete: canDeleteMessage } = (threadId + && message && selectAllowedMessageActions(global, message, threadId)) || {}; + const canDelete = canDeleteMessage || canDeleteAvatar; const messageListType = currentMessageList?.type; return { diff --git a/src/components/ui/AvatarEditable.tsx b/src/components/ui/AvatarEditable.tsx index 2c06c7332..7547bfd22 100644 --- a/src/components/ui/AvatarEditable.tsx +++ b/src/components/ui/AvatarEditable.tsx @@ -45,11 +45,11 @@ const AvatarEditable: FC = ({ setSelectedFile(undefined); onChange(croppedImg); - if (croppedBlobUrl) { + if (croppedBlobUrl && croppedBlobUrl !== currentAvatarBlobUrl) { URL.revokeObjectURL(croppedBlobUrl); } setCroppedBlobUrl(URL.createObjectURL(croppedImg)); - }, [croppedBlobUrl, onChange]); + }, [croppedBlobUrl, currentAvatarBlobUrl, onChange]); const handleModalClose = useCallback(() => { setSelectedFile(undefined); diff --git a/src/global/actions/api/settings.ts b/src/global/actions/api/settings.ts index 850eb2261..602a1e1dc 100644 --- a/src/global/actions/api/settings.ts +++ b/src/global/actions/api/settings.ts @@ -39,7 +39,10 @@ addActionHandler('updateProfile', async (global, actions, payload) => { }); if (photo) { - await callApi('updateProfilePhoto', photo); + const result = await callApi('updateProfilePhoto', photo); + if (result) { + actions.loadProfilePhotos({ profileId: currentUserId }); + } } if (firstName || lastName || about) { @@ -80,6 +83,18 @@ addActionHandler('updateProfile', async (global, actions, payload) => { }); }); +addActionHandler('deleteProfilePhoto', async (global, actions, payload) => { + const { photo, profileId } = payload; + const result = await callApi('deleteProfilePhoto', photo); + if (!result) return; + if (isUserId(profileId)) { + actions.loadFullUser({ userId: profileId }); + } else { + actions.loadFullChat({ chatId: profileId }); + } + actions.loadProfilePhotos({ profileId }); +}); + addActionHandler('checkUsername', async (global, actions, payload) => { const { username } = payload!; diff --git a/src/global/types.ts b/src/global/types.ts index 0e8a5d28c..f34d5815f 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -736,18 +736,19 @@ export interface ActionPayloads { type?: MessageListType; shouldReplaceHistory?: boolean; }; - + loadFullChat: { + chatId: string; + force?: boolean; + }; openChatWithDraft: { chatId?: string; text: string; }; resetOpenChatWithDraft: never; - toggleJoinToSend: { chatId: string; isEnabled: boolean; }; - toggleJoinRequest: { chatId: string; isEnabled: boolean; @@ -857,6 +858,9 @@ export interface ActionPayloads { }; // Users + loadFullUser: { + userId: string; + }; openAddContactDialog: { userId?: string; }; @@ -874,6 +878,13 @@ export interface ActionPayloads { isMuted?: boolean; shouldSharePhoneNumber?: boolean; }; + loadProfilePhotos: { + profileId: string; + }; + deleteProfilePhoto: { + profileId: string; + photo: ApiPhoto; + }; // Forwards openForwardMenu: { @@ -1222,11 +1233,11 @@ export type NonTypedActionNames = ( // chats 'preloadTopChatMessages' | 'loadAllChats' | 'openChatWithInfo' | 'openLinkedChat' | 'openSupportChat' | 'focusMessageInComments' | 'openChatByPhoneNumber' | - 'loadChatSettings' | 'loadFullChat' | 'loadTopChats' | 'requestChatUpdate' | 'updateChatMutedState' | + 'loadChatSettings' | 'loadTopChats' | 'requestChatUpdate' | 'updateChatMutedState' | 'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' | 'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' | 'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' | - 'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' | 'setChatEnabledReactions' | + 'loadMoreMembers' | 'setActiveChatFolder' | 'openNextChat' | 'setChatEnabledReactions' | 'addChatMembers' | 'deleteChatMember' | 'openPreviousChat' | 'editChatFolders' | 'toggleIsProtected' | // messages 'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' | @@ -1261,7 +1272,7 @@ export type NonTypedActionNames = ( 'togglePreHistoryHidden' | 'updateChatDefaultBannedRights' | 'updateChatMemberBannedRights' | 'updateChatAdmin' | 'acceptInviteConfirmation' | // users - 'loadFullUser' | 'loadNearestCountry' | 'loadTopUsers' | 'loadContactList' | + 'loadNearestCountry' | 'loadTopUsers' | 'loadContactList' | 'loadCurrentUser' | 'updateProfile' | 'checkUsername' | 'deleteContact' | 'loadUser' | 'setUserSearchQuery' | 'loadCommonChats' | 'reportSpam' | // chat creation diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 4c5fdc25b..32ea26948 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1233,6 +1233,7 @@ updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; photos.uploadProfilePhoto#89f30f69 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 8f7ad5005..f3e6635f1 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -164,6 +164,7 @@ "updates.getChannelDifference", "photos.uploadProfilePhoto", "photos.getUserPhotos", + "photos.deletePhotos", "upload.saveFilePart", "upload.getFile", "upload.saveBigFilePart",