Media Viewer: Support selecting main photo for profile and chats (#2162)

This commit is contained in:
Alexander Zinchuk 2022-12-06 13:29:46 +01:00
parent be044c2aa1
commit f5b8b09f37
14 changed files with 236 additions and 59 deletions

View File

@ -12,7 +12,7 @@ import type {
ApiChatBannedRights,
ApiChatAdminRights,
ApiGroupCall,
ApiUserStatus,
ApiUserStatus, ApiPhoto,
} from '../../types';
import {
@ -41,7 +41,7 @@ import {
isMessageWithMedia,
buildChatBannedRights,
buildChatAdminRights,
buildInputChatReactions,
buildInputChatReactions, buildInputPhoto,
} from '../gramjsBuilders';
import { addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb } from '../helpers';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
@ -693,24 +693,33 @@ export async function createGroupChat({
export async function editChatPhoto({
chatId, accessHash, photo,
}: {
chatId: string; accessHash?: string; photo: File;
chatId: string; accessHash?: string; photo?: File | ApiPhoto;
}) {
const uploadedPhoto = await uploadFile(photo);
const inputEntity = buildInputEntity(chatId, accessHash);
let inputPhoto: GramJs.TypeInputChatPhoto;
if (photo instanceof File) {
const uploadedPhoto = await uploadFile(photo);
inputPhoto = new GramJs.InputChatUploadedPhoto({
file: uploadedPhoto,
});
} else if (photo) {
const photoId = buildInputPhoto(photo);
if (!photoId) return false;
inputPhoto = new GramJs.InputChatPhoto({
id: photoId,
});
} else {
inputPhoto = new GramJs.InputChatPhotoEmpty();
}
return invokeRequest(
inputEntity instanceof GramJs.InputChannel
? new GramJs.channels.EditPhoto({
channel: inputEntity as GramJs.InputChannel,
photo: new GramJs.InputChatUploadedPhoto({
file: uploadedPhoto,
}),
photo: inputPhoto,
})
: new GramJs.messages.EditChatPhoto({
chatId: inputEntity as BigInt.BigInteger,
photo: new GramJs.InputChatUploadedPhoto({
file: uploadedPhoto,
}),
photo: inputPhoto,
}),
true,
);

View File

@ -54,7 +54,7 @@ export {
export {
updateProfile, checkUsername, updateUsername, fetchBlockedContacts, blockContact, unblockContact,
updateProfilePhoto, uploadProfilePhoto, deleteProfilePhoto, fetchWallpapers, uploadWallpaper,
updateProfilePhoto, uploadProfilePhoto, deleteProfilePhotos, fetchWallpapers, uploadWallpaper,
fetchAuthorizations, terminateAuthorization, terminateAllAuthorizations,
fetchWebAuthorizations, terminateWebAuthorization, terminateAllWebAuthorizations,
fetchNotificationExceptions, fetchNotificationSettings, updateContactSignUpNotification, updateNotificationSettings,

View File

@ -21,6 +21,7 @@ import {
buildPrivacyRules,
} from '../apiBuilders/misc';
import { buildApiPhoto } from '../apiBuilders/common';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
@ -32,7 +33,7 @@ import {
import { getClient, invokeRequest, uploadFile } from './client';
import { buildCollectionByKey } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';
import { addEntitiesWithPhotosToLocalDb } from '../helpers';
import { addEntitiesWithPhotosToLocalDb, addPhotoToLocalDb } from '../helpers';
import localDb from '../localDb';
const MAX_INT_32 = 2 ** 31 - 1;
@ -67,26 +68,32 @@ export function updateUsername(username: string) {
return invokeRequest(new GramJs.account.UpdateUsername({ username }), true);
}
export async function updateProfilePhoto(file: File) {
export async function updateProfilePhoto(photo?: ApiPhoto) {
const photoId = photo ? buildInputPhoto(photo) : new GramJs.InputPhotoEmpty();
const result = await invokeRequest(new GramJs.photos.UpdateProfilePhoto({
id: photoId,
}));
if (result?.photo instanceof GramJs.Photo) {
addPhotoToLocalDb(result.photo);
return buildApiPhoto(result.photo);
}
return undefined;
}
export async function uploadProfilePhoto(file: File) {
const inputFile = await uploadFile(file);
return invokeRequest(new GramJs.photos.UploadProfilePhoto({
file: inputFile,
}), true);
}
export async function uploadProfilePhoto(file: File) {
const inputFile = await uploadFile(file);
await invokeRequest(new GramJs.photos.UploadProfilePhoto({
file: inputFile,
}));
}
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);
export async function deleteProfilePhotos(photos: ApiPhoto[]) {
const photoIds = photos.map(buildInputPhoto).filter(Boolean);
const isDeleted = await invokeRequest(new GramJs.photos.DeletePhotos({ id: photoIds }), true);
if (isDeleted) {
delete localDb.photos[photo.id];
photos.forEach((photo) => {
delete localDb.photos[photo.id];
});
}
return isDeleted;
}

View File

@ -7,6 +7,7 @@ import useLang from '../../hooks/useLang';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { isUserId } from '../../global/helpers';
export type OwnProps = {
isOpen: boolean;
@ -25,13 +26,21 @@ const DeleteProfilePhotoModal: FC<OwnProps> = ({
}) => {
const {
deleteProfilePhoto,
deleteChatPhoto,
} = getActions();
const handleDeletePhoto = useCallback(() => {
onConfirm?.();
deleteProfilePhoto({ photo, profileId });
if (isUserId(profileId)) {
deleteProfilePhoto({ photo });
} else {
deleteChatPhoto({
photo,
chatId: profileId,
});
}
onClose();
}, [onConfirm, deleteProfilePhoto, photo, profileId, onClose]);
}, [onConfirm, profileId, onClose, deleteProfilePhoto, photo, deleteChatPhoto]);
const lang = useLang();

View File

@ -179,7 +179,9 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
}
function renderPhoto(isActive?: boolean) {
const photo = !isSavedMessages && photos.length > 0 ? photos[currentPhotoIndex] : undefined;
const photo = !isSavedMessages && photos.length > 0
? photos[currentPhotoIndex]
: undefined;
return (
<ProfilePhoto
key={currentPhotoIndex}

View File

@ -11,7 +11,7 @@ import { MediaViewerOrigin } from '../../types';
import { getActions, withGlobal } from '../../global';
import {
getChatMediaMessageIds,
getChatMediaMessageIds, isChatAdmin,
} from '../../global/helpers';
import {
selectChat,
@ -59,7 +59,7 @@ type StateProps = {
mediaId?: number;
senderId?: string;
isChatWithSelf?: boolean;
canDeleteMedia?: boolean;
canUpdateMedia?: boolean;
origin?: MediaViewerOrigin;
avatarOwner?: ApiChat | ApiUser;
message?: ApiMessage;
@ -78,7 +78,7 @@ const MediaViewer: FC<StateProps> = ({
mediaId,
senderId,
isChatWithSelf,
canDeleteMedia,
canUpdateMedia,
origin,
avatarOwner,
message,
@ -336,11 +336,12 @@ const MediaViewer: FC<StateProps> = ({
mediaData={bestData}
isVideo={isVideo}
message={message}
canDeleteAvatar={canDeleteMedia && !!avatarPhoto}
canUpdateMedia={canUpdateMedia}
avatarPhoto={avatarPhoto}
avatarOwnerId={avatarOwner?.id}
avatarOwner={avatarOwner}
fileName={fileName}
canReport={canReport}
selectMedia={selectMedia}
onBeforeDelete={handleBeforeDelete}
onReport={openReportModal}
onCloseMediaViewer={handleClose}
@ -422,10 +423,13 @@ export default memo(withGlobal(
if (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);
let canUpdateMedia = false;
if (user) {
canUpdateMedia = avatarOwnerId === currentUserId;
} else if (chat) {
canUpdateMedia = isChatAdmin(chat);
}
isChatWithSelf = selectIsChatWithSelf(global, avatarOwnerId);
return {
@ -433,7 +437,7 @@ export default memo(withGlobal(
senderId: avatarOwnerId,
avatarOwner: user || chat,
isChatWithSelf,
canDeleteMedia,
canUpdateMedia,
animationLevel,
origin,
shouldSkipHistoryAnimations,

View File

@ -7,7 +7,7 @@ import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type {
ApiMessage, ApiPhoto,
ApiMessage, ApiPhoto, ApiChat, ApiUser,
} from '../../api/types';
import type { MessageListType } from '../../global/types';
import type { MenuItemProps } from '../ui/MenuItem';
@ -20,7 +20,7 @@ import {
selectIsChatProtected,
} from '../../global/selectors';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { getMessageMediaFormat, getMessageMediaHash } from '../../global/helpers';
import { getMessageMediaFormat, getMessageMediaHash, isUserId } from '../../global/helpers';
import useLang from '../../hooks/useLang';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
@ -40,7 +40,9 @@ type StateProps = {
isProtected?: boolean;
isChatProtected?: boolean;
canDelete?: boolean;
canUpdate?: boolean;
messageListType?: MessageListType;
avatarOwnerId?: string;
};
type OwnProps = {
@ -48,11 +50,13 @@ type OwnProps = {
isVideo: boolean;
zoomLevelChange: number;
message?: ApiMessage;
canDeleteAvatar?: boolean;
canUpdateMedia?: boolean;
isSingleMedia?: boolean;
avatarPhoto?: ApiPhoto;
avatarOwnerId?: string;
avatarOwner?: ApiChat | ApiUser;
fileName?: string;
canReport?: boolean;
selectMedia: (mediaId?: number) => void;
onReport: NoneToVoidFunction;
onBeforeDelete: NoneToVoidFunction;
onCloseMediaViewer: NoneToVoidFunction;
@ -73,7 +77,9 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
canReport,
zoomLevelChange,
canDelete,
canUpdate,
messageListType,
selectMedia,
onReport,
onCloseMediaViewer,
onBeforeDelete,
@ -85,6 +91,8 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
const {
downloadMessageMedia,
cancelMessageMediaDownload,
updateProfilePhoto,
updateChatPhoto,
} = getActions();
const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
@ -111,6 +119,16 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
setZoomLevelChange(change + 1);
}, [setZoomLevelChange, zoomLevelChange]);
const handleUpdate = useCallback(() => {
if (!avatarPhoto || !avatarOwnerId) return;
if (isUserId(avatarOwnerId)) {
updateProfilePhoto({ photo: avatarPhoto });
} else {
updateChatPhoto({ chatId: avatarOwnerId, photo: avatarPhoto });
}
selectMedia(0);
}, [avatarPhoto, avatarOwnerId, selectMedia, updateProfilePhoto, updateChatPhoto]);
const lang = useLang();
const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
@ -217,6 +235,14 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
});
}
if (canUpdate) {
menuItems.push({
icon: 'copy-media',
onClick: handleUpdate,
children: lang('ProfilePhoto.SetMainPhoto'),
});
}
if (canDelete) {
menuItems.push({
icon: 'delete',
@ -298,6 +324,17 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
<i className="icon-flag" />
</Button>
)}
{canUpdate && (
<Button
round
size="smaller"
color="translucent-white"
ariaLabel={lang('ProfilePhoto.SetMainPhoto')}
onClick={handleUpdate}
>
<i className="icon-copy-media" />
</Button>
)}
{canDelete && (
<Button
round
@ -324,7 +361,9 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>(
(global, { message, canDeleteAvatar }): StateProps => {
(global, {
message, canUpdateMedia, avatarPhoto, avatarOwner,
}): StateProps => {
const currentMessageList = selectCurrentMessageList(global);
const { threadId } = selectCurrentMessageList(global) || {};
const isDownloading = message ? selectIsDownloading(global, message) : false;
@ -332,7 +371,10 @@ export default memo(withGlobal<OwnProps>(
const isChatProtected = message && selectIsChatProtected(global, message?.chatId);
const { canDelete: canDeleteMessage } = (threadId
&& message && selectAllowedMessageActions(global, message, threadId)) || {};
const isCurrentAvatar = avatarPhoto && (avatarPhoto.id === avatarOwner?.avatarHash);
const canDeleteAvatar = canUpdateMedia && !!avatarPhoto;
const canDelete = canDeleteMessage || canDeleteAvatar;
const canUpdate = canUpdateMedia && !!avatarPhoto && !isCurrentAvatar;
const messageListType = currentMessageList?.type;
return {
@ -340,7 +382,9 @@ export default memo(withGlobal<OwnProps>(
isProtected,
isChatProtected,
canDelete,
canUpdate,
messageListType,
avatarOwnerId: avatarOwner?.id,
};
},
)(MediaViewerActions));

View File

@ -923,6 +923,56 @@ addActionHandler('updateChat', async (global, actions, payload) => {
setGlobal(updateManagementProgress(getGlobal(), ManagementProgress.Complete));
});
addActionHandler('updateChatPhoto', async (global, actions, payload) => {
const { photo, chatId } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
setGlobal(updateChat(global, chatId, {
avatarHash: undefined,
fullInfo: {
...chat.fullInfo,
profilePhoto: undefined,
},
}));
// This method creates a new entry in photos array
await callApi('editChatPhoto', {
chatId,
accessHash: chat.accessHash,
photo,
});
// Explicitly delete the old photo reference
await callApi('deleteProfilePhotos', [photo]);
actions.loadFullChat({ chatId });
actions.loadProfilePhotos({ profileId: chatId });
});
addActionHandler('deleteChatPhoto', async (global, actions, payload) => {
const { photo, chatId } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
// Select next photo to set as avatar
const nextPhoto = chat.photos?.[1];
setGlobal(updateChat(global, chatId, {
avatarHash: undefined,
fullInfo: {
...chat.fullInfo,
profilePhoto: undefined,
},
}));
// Set next photo as avatar
await callApi('editChatPhoto', {
chatId,
accessHash: chat.accessHash,
photo: nextPhoto,
});
// Delete references to the old photos
const photosToDelete = [photo, nextPhoto].filter(Boolean);
const result = await callApi('deleteProfilePhotos', photosToDelete);
if (!result) return;
actions.loadFullChat({ chatId });
actions.loadProfilePhotos({ profileId: chatId });
});
addActionHandler('toggleSignatures', (global, actions, payload) => {
const { chatId, isEnabled } = payload!;
const chat = selectChat(global, chatId);

View File

@ -14,7 +14,7 @@ import { callApi } from '../../../api/gramjs';
import { buildCollectionByKey } from '../../../util/iteratees';
import { subscribe, unsubscribe } from '../../../util/notifications';
import { setTimeFormat } from '../../../util/langProvider';
import { selectUser } from '../../selectors';
import { selectUser, selectChat } from '../../selectors';
import {
addUsers, addBlockedContact, updateChats, updateUser, removeBlockedContact, replaceSettings, updateNotifySettings,
addNotifyExceptions,
@ -39,7 +39,7 @@ addActionHandler('updateProfile', async (global, actions, payload) => {
});
if (photo) {
const result = await callApi('updateProfilePhoto', photo);
const result = await callApi('uploadProfilePhoto', photo);
if (result) {
actions.loadProfilePhotos({ profileId: currentUserId });
}
@ -83,16 +83,52 @@ 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 });
addActionHandler('updateProfilePhoto', async (global, actions, payload) => {
const { photo } = payload;
const { currentUserId } = global;
if (!currentUserId) return;
const currentUser = selectChat(global, currentUserId);
if (!currentUser) return;
setGlobal(updateUser(global, currentUserId, {
avatarHash: undefined,
fullInfo: {
...currentUser.fullInfo,
profilePhoto: undefined,
},
}));
const newPhoto = await callApi('updateProfilePhoto', photo);
if (newPhoto) {
setGlobal(updateUser(getGlobal(), currentUserId, {
avatarHash: newPhoto.id,
fullInfo: {
...currentUser.fullInfo,
profilePhoto: newPhoto,
},
}));
actions.loadFullUser({ userId: currentUserId });
actions.loadProfilePhotos({ profileId: currentUserId });
}
actions.loadProfilePhotos({ profileId });
});
addActionHandler('deleteProfilePhoto', async (global, actions, payload) => {
const { photo } = payload;
const { currentUserId } = global;
if (!currentUserId) return;
const currentUser = selectChat(global, currentUserId);
if (!currentUser) return;
if (currentUser.avatarHash === photo.id) {
setGlobal(updateUser(global, currentUserId, {
avatarHash: undefined,
fullInfo: {
...currentUser.fullInfo,
profilePhoto: undefined,
},
}));
}
const result = await callApi('deleteProfilePhotos', [photo]);
if (!result) return;
actions.loadFullUser({ userId: currentUserId });
actions.loadProfilePhotos({ profileId: currentUserId });
});
addActionHandler('checkUsername', async (global, actions, payload) => {

View File

@ -251,11 +251,15 @@ addActionHandler('loadProfilePhotos', async (global, actions, payload) => {
global = getGlobal();
const userOrChat = user || chat;
const { photos } = result;
photos.sort((a) => (a.id === userOrChat?.avatarHash ? -1 : 1));
if (isPrivate) {
global = updateUser(global, profileId, { photos: result.photos });
global = updateUser(global, profileId, { photos });
} else {
global = addUsers(global, buildCollectionByKey(result.users!, 'id'));
global = updateChat(global, profileId, { photos: result.photos });
global = updateChat(global, profileId, { photos });
}
setGlobal(global);

View File

@ -741,6 +741,14 @@ export interface ActionPayloads {
chatId: string;
force?: boolean;
};
updateChatPhoto: {
chatId: string;
photo: ApiPhoto;
};
deleteChatPhoto: {
chatId: string;
photo: ApiPhoto;
};
openChatWithDraft: {
chatId?: string;
text: string;
@ -884,7 +892,9 @@ export interface ActionPayloads {
profileId: string;
};
deleteProfilePhoto: {
profileId: string;
photo: ApiPhoto;
};
updateProfilePhoto: {
photo: ApiPhoto;
};

View File

@ -8,7 +8,7 @@
import { default as Api } from '../tl/api';
import { SecurityError } from '../errors';
// eslint-disable-next-line import/no-named-default
import { default as MTProtoPlainSender } from './MTProtoPlainSender';
import type { default as MTProtoPlainSender } from './MTProtoPlainSender';
import { SERVER_KEYS } from '../crypto/RSA';
const bigInt = require('big-integer');

View File

@ -1232,6 +1232,7 @@ messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector<int> = Updates;
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.updateProfilePhoto#72d4742c id:InputPhoto = photos.Photo;
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;

View File

@ -163,6 +163,7 @@
"updates.getDifference",
"updates.getChannelDifference",
"photos.uploadProfilePhoto",
"photos.updateProfilePhoto",
"photos.getUserPhotos",
"photos.deletePhotos",
"upload.saveFilePart",