Profile / Photo: Suggest & Replace Profile Photos (#2333)

This commit is contained in:
Alexander Zinchuk 2023-01-28 02:15:11 +01:00
parent a6b7ebfcaa
commit 14819b271e
35 changed files with 468 additions and 57 deletions

View File

@ -1048,6 +1048,15 @@ function buildAction(
}
} else if (action instanceof GramJs.MessageActionAttachMenuBotAllowed) {
text = 'ActionAttachMenuBotAllowed';
} else if (action instanceof GramJs.MessageActionSuggestProfilePhoto) {
const isVideo = action.photo instanceof GramJs.Photo && action.photo.videoSizes?.length;
text = senderId === currentUserId
? (isVideo ? 'ActionSuggestVideoFromYouDescription' : 'ActionSuggestPhotoFromYouDescription')
: (isVideo ? 'ActionSuggestVideoToYouDescription' : 'ActionSuggestPhotoToYouDescription');
type = 'suggestProfilePhoto';
translationValues.push('%target_user%');
if (targetPeerId) targetUserIds.push(targetPeerId);
} else {
text = 'ChatList.UnsupportedMessage';
}

View File

@ -15,7 +15,7 @@ export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUse
fullUser: {
about, commonChatsCount, pinnedMsgId, botInfo, blocked,
profilePhoto, voiceMessagesForbidden, premiumGifts,
fallbackPhoto,
fallbackPhoto, personalPhoto,
},
users,
} = mtpUserFull;
@ -27,6 +27,7 @@ export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUse
fullInfo: {
...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }),
...(fallbackPhoto instanceof GramJs.Photo && { fallbackPhoto: buildApiPhoto(fallbackPhoto) }),
...(personalPhoto instanceof GramJs.Photo && { personalPhoto: buildApiPhoto(personalPhoto) }),
bio: about,
commonChatsCount,
pinnedMessageId: pinnedMsgId,

View File

@ -62,6 +62,7 @@ export {
fetchLanguages, fetchLangPack, fetchPrivacySettings, setPrivacySettings, registerDevice, unregisterDevice,
updateIsOnline, fetchContentSettings, updateContentSettings, fetchLangStrings, fetchCountryList, fetchAppConfig,
fetchGlobalPrivacySettings, updateGlobalPrivacySettings, toggleUsername, reorderUsernames, fetchConfig,
uploadContactProfilePhoto,
} from './settings';
export {

View File

@ -160,7 +160,9 @@ async function download(
let mimeType;
let fullSize;
if (entity instanceof GramJs.Message) {
if (entity instanceof GramJs.MessageService && entity.action instanceof GramJs.MessageActionSuggestProfilePhoto) {
mimeType = 'image/jpeg';
} else if (entity instanceof GramJs.Message) {
mimeType = getMessageMediaMimeType(entity, sizeType);
if (entity.media instanceof GramJs.MessageMediaDocument && entity.media.document instanceof GramJs.Document) {
fullSize = entity.media.document.size.toJSNumber();

View File

@ -7,7 +7,7 @@ import type {
ApiError,
ApiLangString,
ApiLanguage,
ApiNotifyException, ApiPhoto,
ApiNotifyException, ApiPhoto, ApiUser,
} from '../../types';
import type { ApiPrivacyKey, InputPrivacyRules, LangCode } from '../../../types';
import type { LANG_PACKS } from '../../../config';
@ -101,10 +101,10 @@ export async function updateProfilePhoto(photo?: ApiPhoto, isFallback?: boolean)
return undefined;
}
export async function uploadProfilePhoto(file: File, isFallback?: boolean) {
export async function uploadProfilePhoto(file: File, isFallback?: boolean, isVideo = false, videoTs = 0) {
const inputFile = await uploadFile(file);
const result = await invokeRequest(new GramJs.photos.UploadProfilePhoto({
file: inputFile,
...(isVideo ? { video: inputFile, videoStartTs: videoTs } : { file: inputFile }),
...(isFallback ? { fallback: true } : undefined),
}));
@ -121,6 +121,40 @@ export async function uploadProfilePhoto(file: File, isFallback?: boolean) {
return undefined;
}
export async function uploadContactProfilePhoto({
file, isSuggest, user,
}: {
file?: File;
isSuggest?: boolean;
user: ApiUser;
}) {
const inputFile = file ? await uploadFile(file) : undefined;
const result = await invokeRequest(new GramJs.photos.UploadContactProfilePhoto({
userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser,
file: inputFile,
...(isSuggest ? { suggest: true } : { save: true }),
}));
if (!result) return undefined;
addEntitiesWithPhotosToLocalDb(result.users);
const users = result.users.map(buildApiUser).filter(Boolean);
if (result.photo instanceof GramJs.Photo) {
addPhotoToLocalDb(result.photo);
return {
users,
photo: buildApiPhoto(result.photo),
};
}
return {
users,
photo: undefined,
};
}
export async function deleteProfilePhotos(photos: ApiPhoto[]) {
const photoIds = photos.map(buildInputPhoto).filter(Boolean);
const isDeleted = await invokeRequest(new GramJs.photos.DeletePhotos({ id: photoIds }), true);

View File

@ -46,11 +46,16 @@ export async function fetchFullUser({
}
updateLocalDb(fullInfo);
addUserToLocalDb(fullInfo.users[0], true);
if (fullInfo.fullUser.profilePhoto instanceof GramJs.Photo) {
localDb.photos[fullInfo.fullUser.profilePhoto.id.toString()] = fullInfo.fullUser.profilePhoto;
}
if (fullInfo.fullUser.personalPhoto instanceof GramJs.Photo) {
localDb.photos[fullInfo.fullUser.personalPhoto.id.toString()] = fullInfo.fullUser.personalPhoto;
}
if (fullInfo.fullUser.fallbackPhoto instanceof GramJs.Photo) {
localDb.photos[fullInfo.fullUser.fallbackPhoto.id.toString()] = fullInfo.fullUser.fallbackPhoto;
}

View File

@ -181,7 +181,10 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
return;
}
if (update.message instanceof GramJs.Message && isMessageWithMedia(update.message)) {
if ((update.message instanceof GramJs.Message && isMessageWithMedia(update.message))
|| (update.message instanceof GramJs.MessageService
&& update.message.action instanceof GramJs.MessageActionSuggestProfilePhoto)
) {
addMessageToLocalDb(update.message);
}

View File

@ -263,7 +263,7 @@ export interface ApiAction {
text: string;
targetUserIds?: string[];
targetChatId?: string;
type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'topicCreate' | 'other';
type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'topicCreate' | 'suggestProfilePhoto' | 'other';
photo?: ApiPhoto;
amount?: number;
currency?: string;

View File

@ -42,6 +42,7 @@ export interface ApiUserFullInfo {
botInfo?: ApiBotInfo;
profilePhoto?: ApiPhoto;
fallbackPhoto?: ApiPhoto;
personalPhoto?: ApiPhoto;
noVoiceMessages?: boolean;
premiumGifts?: ApiPremiumGiftOption[];
}

View File

@ -54,8 +54,11 @@ type OwnProps = {
isSavedMessages?: boolean;
withVideo?: boolean;
noLoop?: boolean;
loopIndefinitely?: boolean;
animationLevel?: AnimationLevel;
noPersonalPhoto?: boolean;
lastSyncTime?: number;
showVideoOverwrite?: boolean;
observeIntersection?: ObserveFn;
onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => void;
};
@ -71,8 +74,11 @@ const Avatar: FC<OwnProps> = ({
isSavedMessages,
withVideo,
noLoop,
loopIndefinitely,
lastSyncTime,
showVideoOverwrite,
animationLevel,
noPersonalPhoto,
observeIntersection,
onClick,
}) => {
@ -87,24 +93,30 @@ const Avatar: FC<OwnProps> = ({
let imageHash: string | undefined;
let videoHash: string | undefined;
const shouldShowUserVideo = !VIDEO_AVATARS_DISABLED && animationLevel === ANIMATION_LEVEL_MAX
&& user?.isPremium && user?.hasVideoAvatar;
const shouldShowPhotoVideo = showVideoOverwrite && photo?.isVideo;
const shouldShowVideo = (
!VIDEO_AVATARS_DISABLED && animationLevel === ANIMATION_LEVEL_MAX
&& isIntersecting && withVideo && user?.isPremium && user?.hasVideoAvatar
isIntersecting && withVideo && (shouldShowPhotoVideo || shouldShowUserVideo)
);
const profilePhoto = user?.fullInfo?.profilePhoto;
const shouldLoadVideo = shouldShowVideo && profilePhoto?.isVideo;
const profilePhoto = user?.fullInfo?.personalPhoto || user?.fullInfo?.profilePhoto || user?.fullInfo?.fallbackPhoto;
const hasProfileVideo = profilePhoto?.isVideo;
const shouldLoadVideo = shouldShowVideo && (hasProfileVideo || shouldShowPhotoVideo);
const shouldFetchBig = size === 'jumbo';
if (!isSavedMessages && !isDeleted) {
if (user) {
if (user && !noPersonalPhoto) {
imageHash = getChatAvatarHash(user, shouldFetchBig ? 'big' : undefined);
} else if (chat) {
imageHash = getChatAvatarHash(chat, shouldFetchBig ? 'big' : undefined);
} else if (photo) {
imageHash = `photo${photo.id}?size=m`;
if (photo.isVideo && withVideo) {
videoHash = `videoAvatar${photo.id}?size=u`;
}
}
if (shouldLoadVideo) {
if (hasProfileVideo) {
videoHash = getChatAvatarHash(user!, undefined, 'video');
}
}
@ -120,11 +132,13 @@ const Avatar: FC<OwnProps> = ({
const video = e.currentTarget;
if (!videoBlobUrl) return;
if (loopIndefinitely) return;
videoLoopCountRef.current += 1;
if (videoLoopCountRef.current >= LOOP_COUNT || noLoop) {
video.style.display = 'none';
}
}, [noLoop, videoBlobUrl]);
}, [loopIndefinitely, noLoop, videoBlobUrl]);
const userId = user?.id;
useEffect(() => {
@ -157,8 +171,9 @@ const Avatar: FC<OwnProps> = ({
<OptimizedVideo
canPlay
src={videoBlobUrl}
className={buildClassName(cn.media, 'avatar-media')}
className={buildClassName(cn.media, 'avatar-media', 'poster')}
muted
loop={loopIndefinitely}
autoPlay
disablePictureInPicture
playsInline

View File

@ -258,6 +258,17 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
>
<div className={styles.photoWrapper}>
{renderPhotoTabs()}
{!forceShowSelf && user?.fullInfo?.personalPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
isFirst && styles.fallbackPhotoVisible,
)}
>
<div className={styles.fallbackPhotoContents}>
{lang(user.fullInfo.personalPhoto.isVideo ? 'UserInfo.CustomVideo' : 'UserInfo.CustomPhoto')}
</div>
</div>
)}
{forceShowSelf && user?.fullInfo?.fallbackPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,

View File

@ -56,7 +56,10 @@ const ProfilePhoto: FC<OwnProps> = ({
const isDeleted = user && isDeletedUser(user);
const isRepliesChat = chat && isChatWithRepliesBot(chat.id);
const userOrChat = user || chat;
const currentPhoto = photo || userOrChat?.fullInfo?.profilePhoto || user?.fullInfo?.fallbackPhoto;
const currentPhoto = photo
|| user?.fullInfo?.personalPhoto
|| userOrChat?.fullInfo?.profilePhoto
|| user?.fullInfo?.fallbackPhoto;
const canHaveMedia = userOrChat && !isSavedMessages && !isDeleted && !isRepliesChat;
const { isVideo } = currentPhoto || {};

View File

@ -373,7 +373,7 @@ const LeftColumn: FC<StateProps> = ({
}, [clearTwoFaError, loadPasswordInfo, settingsScreen]);
useOnChange(() => {
if (nextSettingsScreen) {
if (nextSettingsScreen !== undefined) {
setContent(LeftColumnContent.Settings);
setSettingsScreen(nextSettingsScreen);
requestNextSettingsScreen(undefined);

View File

@ -221,6 +221,10 @@
border-radius: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
&.transition-circle {
transition: transform 0.2s ease, opacity 0.2s ease, border-radius 0.2s ease;
}
&.rounded-corners {
border-radius: var(--border-radius-messages);
}

View File

@ -3,26 +3,22 @@ import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact';
import type {
ApiChat, ApiMessage, ApiUser,
} from '../../api/types';
import type { ApiChat, ApiMessage, ApiUser } from '../../api/types';
import type { AnimationLevel } from '../../types';
import { MediaViewerOrigin } from '../../types';
import { getActions, withGlobal } from '../../global';
import {
getChatMediaMessageIds, isChatAdmin, isUserId,
} from '../../global/helpers';
import { getChatMediaMessageIds, isChatAdmin, isUserId } from '../../global/helpers';
import {
selectChat,
selectChatMessage,
selectChatMessages,
selectChatScheduledMessages,
selectCurrentMediaSearch,
selectIsChatWithSelf,
selectListedIds,
selectOutlyingIds,
selectScheduledMessage,
selectChatScheduledMessages,
selectUser,
} from '../../global/selectors';
import { stopCurrentAudio } from '../../util/audioPlayer';
@ -113,6 +109,7 @@ const MediaViewer: FC<StateProps> = ({
webPagePhoto,
webPageVideo,
isVideo,
actionPhoto,
isPhoto,
bestImageData,
bestData,
@ -129,7 +126,7 @@ const MediaViewer: FC<StateProps> = ({
const isVisible = !isHidden && isOpen;
/* Navigation */
const singleMediaId = webPagePhoto || webPageVideo ? mediaId : undefined;
const singleMediaId = webPagePhoto || webPageVideo || actionPhoto ? mediaId : undefined;
const mediaIds = useMemo(() => {
if (singleMediaId) {
@ -460,7 +457,9 @@ export default memo(withGlobal(
}
let collectionIds: number[] | undefined;
if (origin === MediaViewerOrigin.Inline || origin === MediaViewerOrigin.Album) {
if (origin === MediaViewerOrigin.Inline
|| origin === MediaViewerOrigin.Album
|| origin === MediaViewerOrigin.SuggestedAvatar) {
collectionIds = selectOutlyingIds(global, chatId, threadId) || selectListedIds(global, chatId, threadId);
} else if (origin === MediaViewerOrigin.SharedMedia) {
const currentSearch = selectCurrentMediaSearch(global);

View File

@ -18,6 +18,7 @@ import stopEvent from '../../util/stopEvent';
import buildClassName from '../../util/buildClassName';
import { useMediaProps } from './hooks/useMediaProps';
import useAppLayout from '../../hooks/useAppLayout';
import useLang from '../../hooks/useLang';
import Spinner from '../ui/Spinner';
import MediaViewerFooter from './MediaViewerFooter';
@ -78,11 +79,14 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
isMoving,
} = props;
const lang = useLang();
const isGhostAnimation = animationLevel === 2;
const {
isVideo,
isPhoto,
actionPhoto,
bestImageData,
bestData,
dimensions,
@ -101,7 +105,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
setControlsVisible?.(isVisible);
}, [setControlsVisible]);
if (avatarOwner) {
if (avatarOwner || actionPhoto) {
if (!isVideoAvatar) {
return (
<div key={chatId} className="MediaViewerContent">
@ -142,7 +146,9 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
}
if (!message) return undefined;
const textParts = renderMessageText(message);
const textParts = message.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle')
: renderMessageText(message);
const hasFooter = Boolean(textParts);
return (

View File

@ -1,4 +1,4 @@
import type { ApiMessage, ApiDimensions } from '../../../api/types';
import type { ApiDimensions, ApiMessage } from '../../../api/types';
import { MediaViewerOrigin } from '../../../types';
@ -326,6 +326,11 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
mediaSelector = '.avatar-media';
break;
case MediaViewerOrigin.SuggestedAvatar:
containerSelector = `.Transition__slide--active > .MessageList #${getMessageHtmlId(message!.id)}`;
mediaSelector = '.Avatar img';
break;
case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.Inline:
default:
@ -359,7 +364,11 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) {
break;
case MediaViewerOrigin.MiddleHeaderAvatar:
case MediaViewerOrigin.SuggestedAvatar:
ghost.classList.add('circle');
if (origin === MediaViewerOrigin.SuggestedAvatar) {
ghost.classList.add('transition-circle');
}
break;
}
}

View File

@ -18,7 +18,7 @@ import {
getMessageDocument,
getPhotoFullDimensions,
getVideoDimensions,
getMessageFileSize,
getMessageFileSize, getMessageActionPhoto,
} from '../../../global/helpers';
import { useMemo } from '../../../lib/teact/teact';
import useMedia from '../../../hooks/useMedia';
@ -44,6 +44,7 @@ export const useMediaProps = ({
delay,
}: UseMediaProps) => {
const photo = message ? getMessagePhoto(message) : undefined;
const actionPhoto = message ? getMessageActionPhoto(message) : undefined;
const video = message ? getMessageVideo(message) : undefined;
const webPagePhoto = message ? getMessageWebPagePhoto(message) : undefined;
const webPageVideo = message ? getMessageWebPageVideo(message) : undefined;
@ -51,9 +52,9 @@ export const useMediaProps = ({
const isDocumentVideo = message ? isMessageDocumentVideo(message) : false;
const videoSize = message ? getMessageFileSize(message) : undefined;
const avatarMedia = avatarOwner?.photos?.[mediaId];
const isVideoAvatar = Boolean(avatarMedia?.isVideo);
const isVideoAvatar = Boolean(avatarMedia?.isVideo || actionPhoto?.isVideo);
const isVideo = Boolean(video || webPageVideo || isDocumentVideo);
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto));
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto || actionPhoto));
const { isGif } = video || webPageVideo || {};
const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia;
const isFromSearch = origin === MediaViewerOrigin.SearchResult;
@ -73,8 +74,11 @@ export const useMediaProps = ({
return getChatAvatarHash(avatarOwner, isFull ? 'big' : 'normal');
}
}
if (actionPhoto && isVideoAvatar && isFull) {
return `videoAvatar${actionPhoto.id}?size=u`;
}
return message && getMessageMediaHash(message, isFull ? 'full' : 'preview');
}, [avatarOwner, message, avatarMedia, mediaId]);
}, [avatarOwner, actionPhoto, isVideoAvatar, message, avatarMedia, mediaId]);
const pictogramBlobUrl = useMedia(
message
@ -128,8 +132,8 @@ export const useMediaProps = ({
if (message) {
if (isDocumentPhoto || isDocumentVideo) {
dimensions = getMessageDocument(message)!.mediaSize!;
} else if (photo || webPagePhoto) {
dimensions = getPhotoFullDimensions((photo || webPagePhoto)!)!;
} else if (photo || webPagePhoto || actionPhoto) {
dimensions = getPhotoFullDimensions((photo || webPagePhoto || actionPhoto)!)!;
} else if (video || webPageVideo) {
dimensions = getVideoDimensions((video || webPageVideo)!)!;
}
@ -142,6 +146,7 @@ export const useMediaProps = ({
photo,
video,
webPagePhoto,
actionPhoto,
webPageVideo,
isVideo,
isPhoto,

View File

@ -31,6 +31,7 @@ import useShowTransition from '../../hooks/useShowTransition';
import ContextMenuContainer from './message/ContextMenuContainer.async';
import AnimatedIconFromSticker from '../common/AnimatedIconFromSticker';
import ActionMessageSuggestedAvatar from './ActionMessageSuggestedAvatar';
type OwnProps = {
message: ApiMessage;
@ -96,6 +97,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
const noAppearanceAnimation = appearanceOrder <= 0;
const [isShown, markShown] = useFlag(noAppearanceAnimation);
const isGift = Boolean(message.content.action?.text.startsWith('ActionGift'));
const isSuggestedAvatar = message.content.action?.type === 'suggestProfilePhoto' && message.content.action!.photo;
useEffect(() => {
if (noAppearanceAnimation) {
@ -192,7 +194,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
const className = buildClassName(
'ActionMessage message-list-item',
isFocused && !noFocusHighlight && 'focused',
isGift && 'premium-gift',
(isGift || isSuggestedAvatar) && 'centered-action',
isContextMenuShown && 'has-menu-open',
isLastInList && 'last-in-list',
transitionClassNames,
@ -207,8 +209,14 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
>
<span>{content}</span>
{!isSuggestedAvatar && <span>{content}</span>}
{isGift && renderGift()}
{isSuggestedAvatar && (
<ActionMessageSuggestedAvatar
message={message}
content={content}
/>
)}
{contextMenuPosition && (
<ContextMenuContainer
isOpen={isContextMenuOpen}

View File

@ -0,0 +1,120 @@
import React, { memo, useCallback, useState } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiMessage } from '../../api/types';
import type { TextPart } from '../../types';
import { MediaViewerOrigin, SettingsScreens } from '../../types';
import { ApiMediaFormat, MAIN_THREAD_ID } from '../../api/types';
import { getMessageMediaHash } from '../../global/helpers';
import * as mediaLoader from '../../util/mediaLoader';
import useMedia from '../../hooks/useMedia';
import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import Avatar from '../common/Avatar';
import CropModal from '../ui/CropModal';
import ConfirmDialog from '../ui/ConfirmDialog';
type OwnProps = {
message: ApiMessage;
content?: TextPart;
};
const ActionMessageSuggestedAvatar: FC<OwnProps> = ({
message,
content,
}) => {
const {
openMediaViewer, uploadProfilePhoto, showNotification, requestNextSettingsScreen,
} = getActions();
const { isOutgoing } = message;
const lang = useLang();
const [cropModalBlob, setCropModalBlob] = useState<Blob | undefined>();
const [isVideoModalOpen, openVideoModal, closeVideoModal] = useFlag(false);
const suggestedPhotoUrl = useMedia(getMessageMediaHash(message, 'full'));
const isVideo = message.content.action!.photo?.isVideo;
const showAvatarNotification = useCallback(() => {
showNotification({
title: lang('ApplyAvatarHintTitle'),
message: lang('ApplyAvatarHint'),
action: () => requestNextSettingsScreen(SettingsScreens.Main),
actionText: lang('Open'),
});
}, [lang, requestNextSettingsScreen, showNotification]);
const handleSetSuggestedAvatar = useCallback((file: File) => {
setCropModalBlob(undefined);
uploadProfilePhoto({ file });
showAvatarNotification();
}, [showAvatarNotification, uploadProfilePhoto]);
const handleCloseCropModal = useCallback(() => {
setCropModalBlob(undefined);
}, []);
const handleSetVideo = useCallback(async () => {
closeVideoModal();
showAvatarNotification();
// TODO Once we support uploading video avatars, add crop/trim modal here
const photo = message.content.action!.photo!;
const blobUrl = await mediaLoader.fetch(`videoAvatar${photo.id}?size=u`, ApiMediaFormat.BlobUrl);
const blob = await fetch(blobUrl).then((r) => r.blob());
uploadProfilePhoto({
file: new File([blob], 'avatar.mp4'),
isVideo: true,
videoTs: photo.videoSizes?.find((l) => l.videoStartTs !== undefined)?.videoStartTs,
});
}, [closeVideoModal, message.content.action, showAvatarNotification, uploadProfilePhoto]);
const handleViewSuggestedAvatar = async () => {
if (!isOutgoing && suggestedPhotoUrl) {
if (isVideo) {
openVideoModal();
} else {
setCropModalBlob(await fetch(suggestedPhotoUrl).then((r) => r.blob()));
}
} else {
openMediaViewer({
chatId: message.chatId,
mediaId: message.id,
threadId: MAIN_THREAD_ID,
origin: MediaViewerOrigin.SuggestedAvatar,
});
}
};
return (
<span className="action-message-suggested-avatar" tabIndex={0} role="button" onClick={handleViewSuggestedAvatar}>
<Avatar
photo={message.content.action!.photo}
showVideoOverwrite
loopIndefinitely
withVideo={isVideo}
size="jumbo"
/>
<span>{content}</span>
<span className="action-message-button">{lang(isVideo ? 'ViewVideoAction' : 'ViewPhotoAction')}</span>
<CropModal
file={cropModalBlob}
onClose={handleCloseCropModal}
onChange={handleSetSuggestedAvatar}
/>
<ConfirmDialog
isOpen={isVideoModalOpen}
title={lang('SuggestedVideo')}
confirmHandler={handleSetVideo}
onClose={closeVideoModal}
textParts={content}
/>
</span>
);
};
export default memo(ActionMessageSuggestedAvatar);

View File

@ -279,7 +279,7 @@
}
}
.ActionMessage.premium-gift {
.ActionMessage.centered-action {
display: flex;
flex-direction: column;
align-items: center;
@ -296,6 +296,24 @@
outline: none;
}
.action-message-suggested-avatar {
max-width: 16rem;
display: flex !important;
flex-direction: column;
align-items: center;
line-height: 1rem !important;
padding-bottom: 0.75rem !important;
margin-top: 0.5rem;
cursor: pointer;
outline: none;
.Avatar {
width: 6.5rem;
height: 6.5rem;
margin: 1rem;
}
}
.action-message-button {
display: inline-block;
border-radius: var(--border-radius-default);

View File

@ -1,17 +1,18 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useState,
memo, useCallback, useEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiUser } from '../../../api/types';
import { ManagementProgress } from '../../../types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import {
selectChat, selectNotifyExceptions, selectNotifySettings, selectUser,
} from '../../../global/selectors';
import { selectIsChatMuted } from '../../../global/helpers';
import { isUserBot, selectIsChatMuted } from '../../../global/helpers';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack';
@ -23,6 +24,8 @@ import FloatingActionButton from '../../ui/FloatingActionButton';
import Spinner from '../../ui/Spinner';
import PrivateChatInfo from '../../common/PrivateChatInfo';
import ConfirmDialog from '../../ui/ConfirmDialog';
import SelectAvatar from '../../ui/SelectAvatar';
import Avatar from '../../common/Avatar';
import './Management.scss';
@ -52,9 +55,11 @@ const ManageUser: FC<OwnProps & StateProps> = ({
updateContact,
deleteContact,
closeManagement,
uploadContactProfilePhoto,
} = getActions();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
const [isResetPersonalPhotoDialogOpen, openResetPersonalPhotoDialog, closeResetPersonalPhotoDialog] = useFlag();
const [isProfileFieldsTouched, setIsProfileFieldsTouched] = useState(false);
const [error, setError] = useState<string | undefined>();
const lang = useLang();
@ -130,11 +135,39 @@ const ManageUser: FC<OwnProps & StateProps> = ({
closeManagement();
}, [closeDeleteDialog, closeManagement, deleteContact, userId]);
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLInputElement>(null);
const isSuggestRef = useRef(false);
const handleSuggestPhoto = useCallback(() => {
inputRef.current?.click();
isSuggestRef.current = true;
}, []);
const handleSetPersonalPhoto = useCallback(() => {
inputRef.current?.click();
isSuggestRef.current = false;
}, []);
const handleResetPersonalAvatar = useCallback(() => {
closeResetPersonalPhotoDialog();
setIsProfileFieldsTouched(true);
uploadContactProfilePhoto({ userId });
}, [closeResetPersonalPhotoDialog, uploadContactProfilePhoto, userId]);
const handleSelectAvatar = useCallback((file: File) => {
setIsProfileFieldsTouched(true);
uploadContactProfilePhoto({ userId, file, isSuggest: isSuggestRef.current });
}, [uploadContactProfilePhoto, userId]);
if (!user) {
return undefined;
}
const canSetPersonalPhoto = !isUserBot(user) && user.id !== SERVICE_NOTIFICATIONS_USER_ID;
const isLoading = progress === ManagementProgress.InProgress;
const personalPhoto = user.fullInfo?.personalPhoto;
const notPersonalPhoto = user.fullInfo?.profilePhoto || user.fullInfo?.fallbackPhoto;
return (
<div className="Management">
@ -170,6 +203,34 @@ const ManageUser: FC<OwnProps & StateProps> = ({
/>
</div>
</div>
{canSetPersonalPhoto && (
<div className="section">
<ListItem icon="camera-add" ripple onClick={handleSuggestPhoto}>
{lang('UserInfo.SuggestPhoto', user.firstName)}
</ListItem>
<ListItem icon="camera-add" ripple onClick={handleSetPersonalPhoto}>
{lang('UserInfo.SetCustomPhoto', user.firstName)}
</ListItem>
{personalPhoto && (
<ListItem
leftElement={(
<Avatar
photo={notPersonalPhoto}
noPersonalPhoto
user={user}
size="mini"
className="personal-photo"
/>
)}
ripple
onClick={openResetPersonalPhotoDialog}
>
{lang('UserInfo.ResetCustomPhoto')}
</ListItem>
)}
<p className="text-muted" dir="auto">{lang('UserInfo.CustomPhotoInfo', user.firstName)}</p>
</div>
)}
<div className="section">
<ListItem icon="delete" ripple destructive onClick={openDeleteDialog}>
{lang('DeleteContact')}
@ -196,6 +257,18 @@ const ManageUser: FC<OwnProps & StateProps> = ({
confirmHandler={handleDeleteContact}
confirmIsDestructive
/>
<ConfirmDialog
isOpen={isResetPersonalPhotoDialogOpen}
onClose={closeResetPersonalPhotoDialog}
text={lang('UserInfo.ResetToOriginalAlertText', user.firstName)}
confirmLabel={lang('Reset')}
confirmHandler={handleResetPersonalAvatar}
confirmIsDestructive
/>
<SelectAvatar
onChange={handleSelectAvatar}
inputRef={inputRef}
/>
</div>
);
};

View File

@ -9,6 +9,11 @@
overflow-x: hidden;
}
.personal-photo {
margin-right: 2rem;
transform: scale(1.25);
}
.section {
padding: 1rem 1.5rem;
background-color: var(--color-background);

View File

@ -15,7 +15,7 @@ type OwnProps = {
onCloseAnimationEnd?: () => void;
title?: string;
header?: TeactNode;
textParts?: TextPart[];
textParts?: TextPart;
text?: string;
confirmLabel?: string;
confirmHandler: () => void;

View File

@ -37,7 +37,7 @@ async function ensureCroppie() {
let cropper: Croppie;
async function initCropper(imgFile: File) {
async function initCropper(imgFile: Blob) {
try {
const cropContainer = document.getElementById('avatar-crop');
if (!cropContainer) {
@ -70,7 +70,7 @@ async function initCropper(imgFile: File) {
}
type OwnProps = {
file?: File;
file?: Blob;
onChange: (file: File) => void;
onClose: () => void;
};

View File

@ -92,9 +92,11 @@ addActionHandler('setAuthPassword', (global, actions, payload) => {
});
addActionHandler('uploadProfilePhoto', async (global, actions, payload) => {
const { file, isFallback } = payload!;
const {
file, isFallback, isVideo, videoTs,
} = payload!;
const result = await callApi('uploadProfilePhoto', file, isFallback);
const result = await callApi('uploadProfilePhoto', file, isFallback, isVideo, videoTs);
if (!result) return;
global = getGlobal();

View File

@ -8,8 +8,9 @@ import {
} from '../../reducers';
import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors';
import { migrateChat } from './chats';
import { isChatBasicGroup } from '../../helpers';
import { getUserFirstOrLastName, isChatBasicGroup } from '../../helpers';
import { buildCollectionByKey } from '../../../util/iteratees';
import * as langProvider from '../../../util/langProvider';
addActionHandler('checkPublicLink', async (global, actions, payload) => {
const { chatId } = selectCurrentMessageList(global) || {};
@ -399,3 +400,52 @@ addActionHandler('hideChatReportPanel', async (global, actions, payload) => {
settings: undefined,
}));
});
addActionHandler('uploadContactProfilePhoto', async (global, actions, payload): Promise<void> => {
const { userId, file, isSuggest } = payload;
const user = selectUser(global, userId);
if (!user) return;
global = updateManagementProgress(global, ManagementProgress.InProgress);
setGlobal(global);
const result = await callApi('uploadContactProfilePhoto', {
user,
file,
isSuggest,
});
if (!result) {
global = getGlobal();
global = updateManagementProgress(global, ManagementProgress.Error);
setGlobal(global);
return;
}
global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id'));
setGlobal(global);
const { id, accessHash } = user;
const newUser = await callApi('fetchFullUser', { id, accessHash });
if (!newUser) {
global = getGlobal();
global = updateManagementProgress(global, ManagementProgress.Error);
setGlobal(global);
return;
}
actions.loadProfilePhotos({ profileId: userId });
global = getGlobal();
global = updateManagementProgress(global, ManagementProgress.Complete);
setGlobal(global);
if (file && !isSuggest) {
actions.showNotification({
message: langProvider.getTranslation('UserInfo.SetCustomPhoto.SuccessPhotoText', getUserFirstOrLastName(user)),
});
}
});

View File

@ -40,7 +40,12 @@ addActionHandler('loadFullUser', async (global, actions, payload) => {
const newUser = await callApi('fetchFullUser', { id, accessHash });
if (!newUser) return;
if (user.avatarHash !== newUser.avatarHash && user.photos?.length) {
const hasChangedAvatarHash = user.avatarHash !== newUser.avatarHash;
const hasChangedProfilePhoto = user.fullInfo?.profilePhoto?.id !== newUser.fullInfo?.profilePhoto?.id;
const hasChangedFallbackPhoto = user.fullInfo?.fallbackPhoto?.id !== newUser.fullInfo?.fallbackPhoto?.id;
const hasChangedPersonalPhoto = user.fullInfo?.personalPhoto?.id !== newUser.fullInfo?.personalPhoto?.id;
if ((hasChangedAvatarHash || hasChangedProfilePhoto || hasChangedFallbackPhoto || hasChangedPersonalPhoto)
&& user.photos?.length) {
actions.loadProfilePhotos({ profileId: userId });
}
});
@ -266,7 +271,9 @@ addActionHandler('loadProfilePhotos', async (global, actions, payload) => {
const { photos, users } = result;
photos.sort((a) => (a.id === userOrChat?.avatarHash ? -1 : 1));
const fallbackPhoto = user?.fullInfo?.fallbackPhoto;
const personalPhoto = user?.fullInfo?.personalPhoto;
if (fallbackPhoto) photos.push(fallbackPhoto);
if (personalPhoto) photos.unshift(personalPhoto);
global = addUsers(global, buildCollectionByKey(users, 'id'));

View File

@ -132,10 +132,13 @@ export function getChatAvatarHash(
const { fullInfo } = owner;
if (type === 'video') {
const userFullInfo = isUserId(owner.id) ? fullInfo as ApiUserFullInfo : undefined;
if (userFullInfo?.personalPhoto?.isVideo) {
return getVideoAvatarMediaHash(userFullInfo.personalPhoto);
}
if (fullInfo?.profilePhoto?.isVideo) {
return getVideoAvatarMediaHash(fullInfo.profilePhoto);
}
const userFullInfo = isUserId(owner.id) ? fullInfo as ApiUserFullInfo : undefined;
if (userFullInfo?.fallbackPhoto?.isVideo) {
return getVideoAvatarMediaHash(userFullInfo.fallbackPhoto);
}

View File

@ -52,6 +52,10 @@ export function getMessagePhoto(message: ApiMessage) {
return message.content.photo;
}
export function getMessageActionPhoto(message: ApiMessage) {
return message.content.action?.type === 'suggestProfilePhoto' ? message.content.action.photo : undefined;
}
export function getMessageVideo(message: ApiMessage) {
return message.content.video;
}
@ -180,13 +184,14 @@ export function getMessageMediaHash(
target: Target,
) {
const {
photo, video, sticker, audio, voice, document,
video, sticker, audio, voice, document,
} = message.content;
const messagePhoto = photo || getMessageWebPagePhoto(message) || getMessageDocumentPhoto(message);
const messagePhoto = getMessagePhoto(message) || getMessageWebPagePhoto(message) || getMessageDocumentPhoto(message);
const actionPhoto = getMessageActionPhoto(message);
const messageVideo = video || getMessageWebPageVideo(message) || getMessageDocumentVideo(message);
const content = messagePhoto || messageVideo || sticker || audio || voice || document;
const content = actionPhoto || messagePhoto || messageVideo || sticker || audio || voice || document;
if (!content) {
return undefined;
}
@ -210,18 +215,18 @@ export function getMessageMediaHash(
}
}
if (messagePhoto) {
if (messagePhoto || actionPhoto) {
switch (target) {
case 'micro':
case 'pictogram':
return `${base}?size=m`;
return `${base}?size=${actionPhoto ? 'a' : 'm'}`;
case 'inline':
return !hasMessageLocalBlobUrl(message) ? `${base}?size=x` : undefined;
return !hasMessageLocalBlobUrl(message) ? `${base}?size=${actionPhoto ? 'b' : 'x'}` : undefined;
case 'preview':
return `${base}?size=x`;
return `${base}?size=${actionPhoto ? 'b' : 'x'}`;
case 'full':
case 'download':
return document ? base : `${base}?size=z`;
return document ? base : `${base}?size=${actionPhoto ? 'c' : 'z'}`;
}
}

View File

@ -1409,6 +1409,12 @@ export interface ActionPayloads {
topicId: number;
};
closeEditTopicPanel: never;
uploadContactProfilePhoto: {
userId: string;
file?: File;
isSuggest?: boolean;
};
}
export type NonTypedActionNames = (

View File

@ -529,9 +529,12 @@ class TelegramClient {
let media;
if (messageOrMedia instanceof constructors.Message) {
media = messageOrMedia.media;
} else if (messageOrMedia instanceof constructors.MessageService) {
media = messageOrMedia.action.photo;
} else {
media = messageOrMedia;
}
if (typeof media === 'string') {
throw new Error('not implemented');
}

View File

@ -1264,6 +1264,7 @@ photos.updateProfilePhoto#1c3d5956 flags:# fallback:flags.0?true id:InputPhoto =
photos.uploadProfilePhoto#89f30f69 flags:# fallback:flags.3?true 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;
photos.uploadContactProfilePhoto#b91a83bf flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double = photos.Photo;
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;
upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool;

View File

@ -271,5 +271,6 @@
"channels.editForumTopic",
"channels.updatePinnedForumTopic",
"channels.deleteTopicHistory",
"channels.toggleParticipantsHidden"
"channels.toggleParticipantsHidden",
"photos.uploadContactProfilePhoto"
]

View File

@ -268,6 +268,7 @@ export enum MediaViewerOrigin {
Album,
ScheduledAlbum,
SearchResult,
SuggestedAvatar,
}
export enum AudioOrigin {