Settings: Support deleting profile photos (#2133)

This commit is contained in:
Alexander Zinchuk 2022-11-16 16:16:34 +04:00
parent 2b37128066
commit 6138d1a5f7
12 changed files with 186 additions and 40 deletions

View File

@ -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,

View File

@ -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') }));

View File

@ -127,7 +127,7 @@ const Avatar: FC<OwnProps> = ({
const userId = user?.id;
useEffect(() => {
if (shouldShowVideo && !profilePhoto) {
if (userId && shouldShowVideo && !profilePhoto) {
loadFullUser({ userId });
}
}, [loadFullUser, profilePhoto, userId, shouldShowVideo]);

View File

@ -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<OwnProps & StateProps> = ({
contactName,
willDeleteForCurrentUserOnly,
willDeleteForAll,
onConfirm,
onClose,
}) => {
const {
@ -56,14 +58,16 @@ const DeleteMessageModal: FC<OwnProps & StateProps> = ({
} = 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<OwnProps & StateProps> = ({
});
}
onClose();
}, [album, message.id, isSchedule, onClose, deleteScheduledMessages, deleteMessages]);
}, [onConfirm, album, message.id, isSchedule, onClose, deleteScheduledMessages, deleteMessages]);
const lang = useLang();

View File

@ -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<OwnProps> = ({
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
onEnter={handleDeletePhoto}
className="delete"
title="Are you sure?"
>
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeletePhoto}>
{lang('Preview.DeletePhoto')}
</Button>
<Button className="confirm-dialog-button" isText onClick={onClose}>{lang('Cancel')}</Button>
</Modal>
);
};
export default memo(DeleteProfilePhotoModal);

View File

@ -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<StateProps> = ({
mediaId,
senderId,
isChatWithSelf,
canDeleteMedia,
origin,
avatarOwner,
message,
@ -145,6 +147,12 @@ const MediaViewer: FC<StateProps> = ({
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<StateProps> = ({
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 ? (
<SenderInfo
key={avatarOwner.id}
key={mediaId}
chatId={avatarOwner.id}
isAvatar
/>
@ -324,8 +343,12 @@ const MediaViewer: FC<StateProps> = ({
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,

View File

@ -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<OwnProps & StateProps> = ({
mediaData,
isVideo,
message,
avatarPhoto,
avatarOwnerId,
fileName,
isChatProtected,
isDownloading,
@ -67,6 +76,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
messageListType,
onReport,
onCloseMediaViewer,
onBeforeDelete,
onForward,
setZoomLevelChange,
}) => {
@ -118,6 +128,28 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
);
}, []);
function renderDeleteModals() {
return message
? (
<DeleteMessageModal
isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal}
onConfirm={onBeforeDelete}
message={message}
/>
)
: (avatarOwnerId && avatarPhoto) ? (
<DeleteProfilePhotoModal
isOpen={isDeleteModalOpen}
onClose={closeDeleteModal}
onConfirm={onBeforeDelete}
profileId={avatarOwnerId}
photo={avatarPhoto}
/>
) : undefined;
}
function renderDownloadButton() {
if (isProtected) {
return undefined;
@ -218,14 +250,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
))}
</DropdownMenu>
{isDownloading && <ProgressSpinner progress={downloadProgress} size="s" noCross />}
{message && canDelete && (
<DeleteMessageModal
isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal}
message={message}
/>
)}
{canDelete && renderDeleteModals()}
</div>
);
}
@ -293,26 +318,21 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
>
<i className="icon-close" />
</Button>
{message && canDelete && (
<DeleteMessageModal
isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal}
message={message}
/>
)}
{canDelete && renderDeleteModals()}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(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 {

View File

@ -45,11 +45,11 @@ const AvatarEditable: FC<OwnProps> = ({
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);

View File

@ -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!;

View File

@ -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

View File

@ -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<InputPhoto> = Vector<long>;
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;

View File

@ -164,6 +164,7 @@
"updates.getChannelDifference",
"photos.uploadProfilePhoto",
"photos.getUserPhotos",
"photos.deletePhotos",
"upload.saveFilePart",
"upload.getFile",
"upload.saveBigFilePart",