diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index f81a1a725..9f83fa138 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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'; } diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index ea4084a66..55b0e8f10 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -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, diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 44f6853fb..31b948c1f 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -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 { diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index 633a4dda4..3cff93748 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -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(); diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index b363f271f..a4e494a19 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -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); diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 70bfea844..4f7cf9bcd 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -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; } diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index b94b8a7ae..5ddc12bb6 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -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); } diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 71ff93a28..c20fbe64a 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -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; diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 336036b52..c125e6478 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -42,6 +42,7 @@ export interface ApiUserFullInfo { botInfo?: ApiBotInfo; profilePhoto?: ApiPhoto; fallbackPhoto?: ApiPhoto; + personalPhoto?: ApiPhoto; noVoiceMessages?: boolean; premiumGifts?: ApiPremiumGiftOption[]; } diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 329e1347d..23787ec8b 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -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, hasMedia: boolean) => void; }; @@ -71,8 +74,11 @@ const Avatar: FC = ({ isSavedMessages, withVideo, noLoop, + loopIndefinitely, lastSyncTime, + showVideoOverwrite, animationLevel, + noPersonalPhoto, observeIntersection, onClick, }) => { @@ -87,24 +93,30 @@ const Avatar: FC = ({ 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 = ({ 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 = ({ = ({ >
{renderPhotoTabs()} + {!forceShowSelf && user?.fullInfo?.personalPhoto && ( +
+
+ {lang(user.fullInfo.personalPhoto.isVideo ? 'UserInfo.CustomVideo' : 'UserInfo.CustomPhoto')} +
+
+ )} {forceShowSelf && user?.fullInfo?.fallbackPhoto && (
= ({ 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 || {}; diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index 7d572eb33..25af47560 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -373,7 +373,7 @@ const LeftColumn: FC = ({ }, [clearTwoFaError, loadPasswordInfo, settingsScreen]); useOnChange(() => { - if (nextSettingsScreen) { + if (nextSettingsScreen !== undefined) { setContent(LeftColumnContent.Settings); setSettingsScreen(nextSettingsScreen); requestNextSettingsScreen(undefined); diff --git a/src/components/mediaViewer/MediaViewer.scss b/src/components/mediaViewer/MediaViewer.scss index 31342b533..ce2d01ea7 100644 --- a/src/components/mediaViewer/MediaViewer.scss +++ b/src/components/mediaViewer/MediaViewer.scss @@ -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); } diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 81b4cd77c..74920da80 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -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 = ({ webPagePhoto, webPageVideo, isVideo, + actionPhoto, isPhoto, bestImageData, bestData, @@ -129,7 +126,7 @@ const MediaViewer: FC = ({ 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); diff --git a/src/components/mediaViewer/MediaViewerContent.tsx b/src/components/mediaViewer/MediaViewerContent.tsx index 26bec2081..397ac5ca8 100644 --- a/src/components/mediaViewer/MediaViewerContent.tsx +++ b/src/components/mediaViewer/MediaViewerContent.tsx @@ -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 = (props) => { isMoving, } = props; + const lang = useLang(); + const isGhostAnimation = animationLevel === 2; const { isVideo, isPhoto, + actionPhoto, bestImageData, bestData, dimensions, @@ -101,7 +105,7 @@ const MediaViewerContent: FC = (props) => { setControlsVisible?.(isVisible); }, [setControlsVisible]); - if (avatarOwner) { + if (avatarOwner || actionPhoto) { if (!isVideoAvatar) { return (
@@ -142,7 +146,9 @@ const MediaViewerContent: FC = (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 ( diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts index ead25680f..3cf9e334a 100644 --- a/src/components/mediaViewer/helpers/ghostAnimation.ts +++ b/src/components/mediaViewer/helpers/ghostAnimation.ts @@ -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; } } diff --git a/src/components/mediaViewer/hooks/useMediaProps.ts b/src/components/mediaViewer/hooks/useMediaProps.ts index 020b16f25..2a5a990cb 100644 --- a/src/components/mediaViewer/hooks/useMediaProps.ts +++ b/src/components/mediaViewer/hooks/useMediaProps.ts @@ -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, diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index f9e7602fd..e4536bcbe 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -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 = ({ 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 = ({ 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 = ({ onMouseDown={handleMouseDown} onContextMenu={handleContextMenu} > - {content} + {!isSuggestedAvatar && {content}} {isGift && renderGift()} + {isSuggestedAvatar && ( + + )} {contextMenuPosition && ( = ({ + message, + content, +}) => { + const { + openMediaViewer, uploadProfilePhoto, showNotification, requestNextSettingsScreen, + } = getActions(); + + const { isOutgoing } = message; + + const lang = useLang(); + const [cropModalBlob, setCropModalBlob] = useState(); + 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 ( + + + {content} + + {lang(isVideo ? 'ViewVideoAction' : 'ViewPhotoAction')} + + + + ); +}; + +export default memo(ActionMessageSuggestedAvatar); diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index 28abf8759..b2d470e72 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -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); diff --git a/src/components/right/management/ManageUser.tsx b/src/components/right/management/ManageUser.tsx index 593a860c4..d6d95dee9 100644 --- a/src/components/right/management/ManageUser.tsx +++ b/src/components/right/management/ManageUser.tsx @@ -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 = ({ updateContact, deleteContact, closeManagement, + uploadContactProfilePhoto, } = getActions(); const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); + const [isResetPersonalPhotoDialogOpen, openResetPersonalPhotoDialog, closeResetPersonalPhotoDialog] = useFlag(); const [isProfileFieldsTouched, setIsProfileFieldsTouched] = useState(false); const [error, setError] = useState(); const lang = useLang(); @@ -130,11 +135,39 @@ const ManageUser: FC = ({ closeManagement(); }, [closeDeleteDialog, closeManagement, deleteContact, userId]); + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(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 (
@@ -170,6 +203,34 @@ const ManageUser: FC = ({ />
+ {canSetPersonalPhoto && ( +
+ + {lang('UserInfo.SuggestPhoto', user.firstName)} + + + {lang('UserInfo.SetCustomPhoto', user.firstName)} + + {personalPhoto && ( + + )} + ripple + onClick={openResetPersonalPhotoDialog} + > + {lang('UserInfo.ResetCustomPhoto')} + + )} +

{lang('UserInfo.CustomPhotoInfo', user.firstName)}

+
+ )}
{lang('DeleteContact')} @@ -196,6 +257,18 @@ const ManageUser: FC = ({ confirmHandler={handleDeleteContact} confirmIsDestructive /> + +
); }; diff --git a/src/components/right/management/Management.scss b/src/components/right/management/Management.scss index 93234f55b..5d4fd725c 100644 --- a/src/components/right/management/Management.scss +++ b/src/components/right/management/Management.scss @@ -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); diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index 5cebf4489..06f0c9f5f 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -15,7 +15,7 @@ type OwnProps = { onCloseAnimationEnd?: () => void; title?: string; header?: TeactNode; - textParts?: TextPart[]; + textParts?: TextPart; text?: string; confirmLabel?: string; confirmHandler: () => void; diff --git a/src/components/ui/CropModal.tsx b/src/components/ui/CropModal.tsx index 541e8f149..e3de729f2 100644 --- a/src/components/ui/CropModal.tsx +++ b/src/components/ui/CropModal.tsx @@ -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; }; diff --git a/src/global/actions/api/initial.ts b/src/global/actions/api/initial.ts index 74dfa0fb2..b6c656587 100644 --- a/src/global/actions/api/initial.ts +++ b/src/global/actions/api/initial.ts @@ -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(); diff --git a/src/global/actions/api/management.ts b/src/global/actions/api/management.ts index a0dac05f2..d2579a862 100644 --- a/src/global/actions/api/management.ts +++ b/src/global/actions/api/management.ts @@ -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 => { + 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)), + }); + } +}); diff --git a/src/global/actions/api/users.ts b/src/global/actions/api/users.ts index 1a52b9c3f..df168822a 100644 --- a/src/global/actions/api/users.ts +++ b/src/global/actions/api/users.ts @@ -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')); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index b1ade130b..11a560256 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -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); } diff --git a/src/global/helpers/messageMedia.ts b/src/global/helpers/messageMedia.ts index 2a7ee2afd..942b434b9 100644 --- a/src/global/helpers/messageMedia.ts +++ b/src/global/helpers/messageMedia.ts @@ -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'}`; } } diff --git a/src/global/types.ts b/src/global/types.ts index 1627ea812..4ac96412d 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -1409,6 +1409,12 @@ export interface ActionPayloads { topicId: number; }; closeEditTopicPanel: never; + + uploadContactProfilePhoto: { + userId: string; + file?: File; + isSuggest?: boolean; + }; } export type NonTypedActionNames = ( diff --git a/src/lib/gramjs/client/TelegramClient.js b/src/lib/gramjs/client/TelegramClient.js index 21cfbeed2..08512e2dd 100644 --- a/src/lib/gramjs/client/TelegramClient.js +++ b/src/lib/gramjs/client/TelegramClient.js @@ -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'); } diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 37352ae40..753f6be57 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -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 = Vector; 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; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 3601a4d08..c78736323 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -271,5 +271,6 @@ "channels.editForumTopic", "channels.updatePinnedForumTopic", "channels.deleteTopicHistory", - "channels.toggleParticipantsHidden" + "channels.toggleParticipantsHidden", + "photos.uploadContactProfilePhoto" ] diff --git a/src/types/index.ts b/src/types/index.ts index 43ca5b593..8acbab0e0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -268,6 +268,7 @@ export enum MediaViewerOrigin { Album, ScheduledAlbum, SearchResult, + SuggestedAvatar, } export enum AudioOrigin {