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) { } else if (action instanceof GramJs.MessageActionAttachMenuBotAllowed) {
text = 'ActionAttachMenuBotAllowed'; 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 { } else {
text = 'ChatList.UnsupportedMessage'; text = 'ChatList.UnsupportedMessage';
} }

View File

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

View File

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

View File

@ -160,7 +160,9 @@ async function download(
let mimeType; let mimeType;
let fullSize; 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); mimeType = getMessageMediaMimeType(entity, sizeType);
if (entity.media instanceof GramJs.MessageMediaDocument && entity.media.document instanceof GramJs.Document) { if (entity.media instanceof GramJs.MessageMediaDocument && entity.media.document instanceof GramJs.Document) {
fullSize = entity.media.document.size.toJSNumber(); fullSize = entity.media.document.size.toJSNumber();

View File

@ -7,7 +7,7 @@ import type {
ApiError, ApiError,
ApiLangString, ApiLangString,
ApiLanguage, ApiLanguage,
ApiNotifyException, ApiPhoto, ApiNotifyException, ApiPhoto, ApiUser,
} from '../../types'; } from '../../types';
import type { ApiPrivacyKey, InputPrivacyRules, LangCode } from '../../../types'; import type { ApiPrivacyKey, InputPrivacyRules, LangCode } from '../../../types';
import type { LANG_PACKS } from '../../../config'; import type { LANG_PACKS } from '../../../config';
@ -101,10 +101,10 @@ export async function updateProfilePhoto(photo?: ApiPhoto, isFallback?: boolean)
return undefined; 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 inputFile = await uploadFile(file);
const result = await invokeRequest(new GramJs.photos.UploadProfilePhoto({ const result = await invokeRequest(new GramJs.photos.UploadProfilePhoto({
file: inputFile, ...(isVideo ? { video: inputFile, videoStartTs: videoTs } : { file: inputFile }),
...(isFallback ? { fallback: true } : undefined), ...(isFallback ? { fallback: true } : undefined),
})); }));
@ -121,6 +121,40 @@ export async function uploadProfilePhoto(file: File, isFallback?: boolean) {
return undefined; 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[]) { export async function deleteProfilePhotos(photos: ApiPhoto[]) {
const photoIds = photos.map(buildInputPhoto).filter(Boolean); const photoIds = photos.map(buildInputPhoto).filter(Boolean);
const isDeleted = await invokeRequest(new GramJs.photos.DeletePhotos({ id: photoIds }), true); const isDeleted = await invokeRequest(new GramJs.photos.DeletePhotos({ id: photoIds }), true);

View File

@ -46,11 +46,16 @@ export async function fetchFullUser({
} }
updateLocalDb(fullInfo); updateLocalDb(fullInfo);
addUserToLocalDb(fullInfo.users[0], true);
if (fullInfo.fullUser.profilePhoto instanceof GramJs.Photo) { if (fullInfo.fullUser.profilePhoto instanceof GramJs.Photo) {
localDb.photos[fullInfo.fullUser.profilePhoto.id.toString()] = fullInfo.fullUser.profilePhoto; 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) { if (fullInfo.fullUser.fallbackPhoto instanceof GramJs.Photo) {
localDb.photos[fullInfo.fullUser.fallbackPhoto.id.toString()] = fullInfo.fullUser.fallbackPhoto; 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; 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); addMessageToLocalDb(update.message);
} }

View File

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

View File

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

View File

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

View File

@ -258,6 +258,17 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
> >
<div className={styles.photoWrapper}> <div className={styles.photoWrapper}>
{renderPhotoTabs()} {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 && ( {forceShowSelf && user?.fullInfo?.fallbackPhoto && (
<div className={buildClassName( <div className={buildClassName(
styles.fallbackPhoto, styles.fallbackPhoto,

View File

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

View File

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

View File

@ -221,6 +221,10 @@
border-radius: 0; border-radius: 0;
transition: transform 0.2s ease, opacity 0.2s ease; 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 { &.rounded-corners {
border-radius: var(--border-radius-messages); border-radius: var(--border-radius-messages);
} }

View File

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

View File

@ -18,6 +18,7 @@ import stopEvent from '../../util/stopEvent';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { useMediaProps } from './hooks/useMediaProps'; import { useMediaProps } from './hooks/useMediaProps';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useLang from '../../hooks/useLang';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import MediaViewerFooter from './MediaViewerFooter'; import MediaViewerFooter from './MediaViewerFooter';
@ -78,11 +79,14 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
isMoving, isMoving,
} = props; } = props;
const lang = useLang();
const isGhostAnimation = animationLevel === 2; const isGhostAnimation = animationLevel === 2;
const { const {
isVideo, isVideo,
isPhoto, isPhoto,
actionPhoto,
bestImageData, bestImageData,
bestData, bestData,
dimensions, dimensions,
@ -101,7 +105,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
setControlsVisible?.(isVisible); setControlsVisible?.(isVisible);
}, [setControlsVisible]); }, [setControlsVisible]);
if (avatarOwner) { if (avatarOwner || actionPhoto) {
if (!isVideoAvatar) { if (!isVideoAvatar) {
return ( return (
<div key={chatId} className="MediaViewerContent"> <div key={chatId} className="MediaViewerContent">
@ -142,7 +146,9 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
} }
if (!message) return undefined; if (!message) return undefined;
const textParts = renderMessageText(message); const textParts = message.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle')
: renderMessageText(message);
const hasFooter = Boolean(textParts); const hasFooter = Boolean(textParts);
return ( 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'; import { MediaViewerOrigin } from '../../../types';
@ -326,6 +326,11 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
mediaSelector = '.avatar-media'; mediaSelector = '.avatar-media';
break; break;
case MediaViewerOrigin.SuggestedAvatar:
containerSelector = `.Transition__slide--active > .MessageList #${getMessageHtmlId(message!.id)}`;
mediaSelector = '.Avatar img';
break;
case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.Inline: case MediaViewerOrigin.Inline:
default: default:
@ -359,7 +364,11 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) {
break; break;
case MediaViewerOrigin.MiddleHeaderAvatar: case MediaViewerOrigin.MiddleHeaderAvatar:
case MediaViewerOrigin.SuggestedAvatar:
ghost.classList.add('circle'); ghost.classList.add('circle');
if (origin === MediaViewerOrigin.SuggestedAvatar) {
ghost.classList.add('transition-circle');
}
break; break;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -92,9 +92,11 @@ addActionHandler('setAuthPassword', (global, actions, payload) => {
}); });
addActionHandler('uploadProfilePhoto', async (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; if (!result) return;
global = getGlobal(); global = getGlobal();

View File

@ -8,8 +8,9 @@ import {
} from '../../reducers'; } from '../../reducers';
import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors'; import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors';
import { migrateChat } from './chats'; import { migrateChat } from './chats';
import { isChatBasicGroup } from '../../helpers'; import { getUserFirstOrLastName, isChatBasicGroup } from '../../helpers';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import * as langProvider from '../../../util/langProvider';
addActionHandler('checkPublicLink', async (global, actions, payload) => { addActionHandler('checkPublicLink', async (global, actions, payload) => {
const { chatId } = selectCurrentMessageList(global) || {}; const { chatId } = selectCurrentMessageList(global) || {};
@ -399,3 +400,52 @@ addActionHandler('hideChatReportPanel', async (global, actions, payload) => {
settings: undefined, 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 }); const newUser = await callApi('fetchFullUser', { id, accessHash });
if (!newUser) return; 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 }); actions.loadProfilePhotos({ profileId: userId });
} }
}); });
@ -266,7 +271,9 @@ addActionHandler('loadProfilePhotos', async (global, actions, payload) => {
const { photos, users } = result; const { photos, users } = result;
photos.sort((a) => (a.id === userOrChat?.avatarHash ? -1 : 1)); photos.sort((a) => (a.id === userOrChat?.avatarHash ? -1 : 1));
const fallbackPhoto = user?.fullInfo?.fallbackPhoto; const fallbackPhoto = user?.fullInfo?.fallbackPhoto;
const personalPhoto = user?.fullInfo?.personalPhoto;
if (fallbackPhoto) photos.push(fallbackPhoto); if (fallbackPhoto) photos.push(fallbackPhoto);
if (personalPhoto) photos.unshift(personalPhoto);
global = addUsers(global, buildCollectionByKey(users, 'id')); global = addUsers(global, buildCollectionByKey(users, 'id'));

View File

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

View File

@ -52,6 +52,10 @@ export function getMessagePhoto(message: ApiMessage) {
return message.content.photo; 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) { export function getMessageVideo(message: ApiMessage) {
return message.content.video; return message.content.video;
} }
@ -180,13 +184,14 @@ export function getMessageMediaHash(
target: Target, target: Target,
) { ) {
const { const {
photo, video, sticker, audio, voice, document, video, sticker, audio, voice, document,
} = message.content; } = 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 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) { if (!content) {
return undefined; return undefined;
} }
@ -210,18 +215,18 @@ export function getMessageMediaHash(
} }
} }
if (messagePhoto) { if (messagePhoto || actionPhoto) {
switch (target) { switch (target) {
case 'micro': case 'micro':
case 'pictogram': case 'pictogram':
return `${base}?size=m`; return `${base}?size=${actionPhoto ? 'a' : 'm'}`;
case 'inline': case 'inline':
return !hasMessageLocalBlobUrl(message) ? `${base}?size=x` : undefined; return !hasMessageLocalBlobUrl(message) ? `${base}?size=${actionPhoto ? 'b' : 'x'}` : undefined;
case 'preview': case 'preview':
return `${base}?size=x`; return `${base}?size=${actionPhoto ? 'b' : 'x'}`;
case 'full': case 'full':
case 'download': 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; topicId: number;
}; };
closeEditTopicPanel: never; closeEditTopicPanel: never;
uploadContactProfilePhoto: {
userId: string;
file?: File;
isSuggest?: boolean;
};
} }
export type NonTypedActionNames = ( export type NonTypedActionNames = (

View File

@ -529,9 +529,12 @@ class TelegramClient {
let media; let media;
if (messageOrMedia instanceof constructors.Message) { if (messageOrMedia instanceof constructors.Message) {
media = messageOrMedia.media; media = messageOrMedia.media;
} else if (messageOrMedia instanceof constructors.MessageService) {
media = messageOrMedia.action.photo;
} else { } else {
media = messageOrMedia; media = messageOrMedia;
} }
if (typeof media === 'string') { if (typeof media === 'string') {
throw new Error('not implemented'); 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.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.deletePhotos#87cf7f2f id:Vector<InputPhoto> = Vector<long>;
photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; 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.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.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; 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.editForumTopic",
"channels.updatePinnedForumTopic", "channels.updatePinnedForumTopic",
"channels.deleteTopicHistory", "channels.deleteTopicHistory",
"channels.toggleParticipantsHidden" "channels.toggleParticipantsHidden",
"photos.uploadContactProfilePhoto"
] ]

View File

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