diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 4e4ffd89f..ea4084a66 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -15,6 +15,7 @@ export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUse fullUser: { about, commonChatsCount, pinnedMsgId, botInfo, blocked, profilePhoto, voiceMessagesForbidden, premiumGifts, + fallbackPhoto, }, users, } = mtpUserFull; @@ -25,6 +26,7 @@ export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUse ...user, fullInfo: { ...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }), + ...(fallbackPhoto instanceof GramJs.Photo && { fallbackPhoto: buildApiPhoto(fallbackPhoto) }), bio: about, commonChatsCount, pinnedMessageId: pinnedMsgId, diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index 00074bca0..b363f271f 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -82,10 +82,11 @@ export function updateUsername(username: string) { return invokeRequest(new GramJs.account.UpdateUsername({ username }), true); } -export async function updateProfilePhoto(photo?: ApiPhoto) { +export async function updateProfilePhoto(photo?: ApiPhoto, isFallback?: boolean) { const photoId = photo ? buildInputPhoto(photo) : new GramJs.InputPhotoEmpty(); const result = await invokeRequest(new GramJs.photos.UpdateProfilePhoto({ id: photoId, + ...(isFallback ? { fallback: true } : undefined), })); if (!result) return undefined; @@ -100,10 +101,11 @@ export async function updateProfilePhoto(photo?: ApiPhoto) { return undefined; } -export async function uploadProfilePhoto(file: File) { +export async function uploadProfilePhoto(file: File, isFallback?: boolean) { const inputFile = await uploadFile(file); const result = await invokeRequest(new GramJs.photos.UploadProfilePhoto({ file: inputFile, + ...(isFallback ? { fallback: true } : undefined), })); if (!result) return undefined; diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index d819a9ee4..70bfea844 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -45,10 +45,16 @@ export async function fetchFullUser({ return undefined; } + updateLocalDb(fullInfo); + if (fullInfo.fullUser.profilePhoto instanceof GramJs.Photo) { localDb.photos[fullInfo.fullUser.profilePhoto.id.toString()] = fullInfo.fullUser.profilePhoto; } + if (fullInfo.fullUser.fallbackPhoto instanceof GramJs.Photo) { + localDb.photos[fullInfo.fullUser.fallbackPhoto.id.toString()] = fullInfo.fullUser.fallbackPhoto; + } + const botInfo = fullInfo.fullUser.botInfo; if (botInfo?.descriptionPhoto instanceof GramJs.Photo) { localDb.photos[botInfo.descriptionPhoto.id.toString()] = botInfo.descriptionPhoto; @@ -58,11 +64,14 @@ export async function fetchFullUser({ } const userWithFullInfo = buildApiUserFromFull(fullInfo); + const user = buildApiUser(fullInfo.users[0]); onUpdate({ '@type': 'updateUser', id, user: { + ...user, + avatarHash: user?.avatarHash || undefined, fullInfo: userWithFullInfo.fullInfo, }, }); diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 1d8c259bd..336036b52 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -41,6 +41,7 @@ export interface ApiUserFullInfo { pinnedMessageId?: number; botInfo?: ApiBotInfo; profilePhoto?: ApiPhoto; + fallbackPhoto?: ApiPhoto; noVoiceMessages?: boolean; premiumGifts?: ApiPremiumGiftOption[]; } diff --git a/src/components/common/Avatar.scss b/src/components/common/Avatar.scss index b15a0a43e..c9dbafcdc 100644 --- a/src/components/common/Avatar.scss +++ b/src/components/common/Avatar.scss @@ -52,6 +52,17 @@ } } + &.size-mini { + width: 1.5rem; + height: 1.5rem; + font-size: 0.75rem; + + .emoji { + width: 0.75rem; + height: 0.75rem; + } + } + &.size-small { width: 2.125rem; height: 2.125rem; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 6b07c2d44..329e1347d 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -45,7 +45,7 @@ cn.icon = cn('icon'); type OwnProps = { className?: string; - size?: 'micro' | 'tiny' | 'small' | 'medium' | 'large' | 'jumbo'; + size?: 'micro' | 'tiny' | 'mini' | 'small' | 'medium' | 'large' | 'jumbo'; chat?: ApiChat; user?: ApiUser; photo?: ApiPhoto; diff --git a/src/components/common/ProfileInfo.module.scss b/src/components/common/ProfileInfo.module.scss index d67e02ed9..40640ce1f 100644 --- a/src/components/common/ProfileInfo.module.scss +++ b/src/components/common/ProfileInfo.module.scss @@ -15,6 +15,51 @@ } } +.fallbackPhoto { + position: absolute; + z-index: 1; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%); + width: 100%; + display: flex; + justify-content: center; + padding-top: 1rem; + padding-bottom: 0.5rem; + opacity: 0; + pointer-events: none; + + transition: 0.25s ease-in-out opacity; +} + +.fallbackPhotoContents { + display: flex; + font-size: 0.75rem; + color: var(--color-white); + opacity: 0.5; + cursor: pointer; + user-select: none; + align-items: center; + height: 1.5rem; + pointer-events: none; + + transition: 0.25s ease-in-out opacity; + + &:hover { + opacity: 1; + } +} + +.fallbackPhotoVisible { + opacity: 1; + + .fallbackPhotoContents { + pointer-events: all; + } +} + +.fallbackPhotoAvatar { + margin-right: 0.5rem; +} + .photoWrapper { width: 100%; position: absolute; @@ -33,7 +78,7 @@ width: 100%; height: 0.125rem; padding: 0 0.375rem; - z-index: 1; + z-index: 2; display: flex; top: 0.5rem; diff --git a/src/components/common/ProfileInfo.tsx b/src/components/common/ProfileInfo.tsx index 3adb71c06..200026fe2 100644 --- a/src/components/common/ProfileInfo.tsx +++ b/src/components/common/ProfileInfo.tsx @@ -29,6 +29,7 @@ import FullNameTitle from './FullNameTitle'; import ProfilePhoto from './ProfilePhoto'; import Transition from '../ui/Transition'; import TopicIcon from './TopicIcon'; +import Avatar from './Avatar'; import './ProfileInfo.scss'; import styles from './ProfileInfo.module.scss'; @@ -145,6 +146,12 @@ const ProfileInfo: FC = ({ setCurrentPhotoIndex(currentPhotoIndex + 1); }, [currentPhotoIndex, isLast]); + function handleSelectFallbackPhoto() { + if (!isFirst) return; + setHasSlideAnimation(true); + setCurrentPhotoIndex(photos.length - 1); + } + // Swipe gestures useEffect(() => { const element = document.querySelector(`.${styles.photoWrapper}`); @@ -251,6 +258,24 @@ const ProfileInfo: FC = ({ >
{renderPhotoTabs()} + {forceShowSelf && user?.fullInfo?.fallbackPhoto && ( +
+
+ {!isLast && ( + + )} + {lang(user.fullInfo.fallbackPhoto.isVideo ? 'UserInfo.PublicVideo' : 'UserInfo.PublicPhoto')} +
+
+ )} {renderPhoto} diff --git a/src/components/common/ProfilePhoto.tsx b/src/components/common/ProfilePhoto.tsx index 315e7d606..39767bfdf 100644 --- a/src/components/common/ProfilePhoto.tsx +++ b/src/components/common/ProfilePhoto.tsx @@ -56,7 +56,7 @@ const ProfilePhoto: FC = ({ const isDeleted = user && isDeletedUser(user); const isRepliesChat = chat && isChatWithRepliesBot(chat.id); const userOrChat = user || chat; - const currentPhoto = photo || userOrChat?.fullInfo?.profilePhoto; + const currentPhoto = photo || userOrChat?.fullInfo?.profilePhoto || user?.fullInfo?.fallbackPhoto; const canHaveMedia = userOrChat && !isSavedMessages && !isDeleted && !isRepliesChat; const { isVideo } = currentPhoto || {}; diff --git a/src/components/left/settings/SettingsPrivacyPublicPhoto.module.scss b/src/components/left/settings/SettingsPrivacyPublicPhoto.module.scss new file mode 100644 index 000000000..c28ee8004 --- /dev/null +++ b/src/components/left/settings/SettingsPrivacyPublicPhoto.module.scss @@ -0,0 +1,4 @@ +.fallback-photo { + margin-right: 2rem; + transform: scale(1.25); +} diff --git a/src/components/left/settings/SettingsPrivacyPublicProfilePhoto.tsx b/src/components/left/settings/SettingsPrivacyPublicProfilePhoto.tsx new file mode 100644 index 000000000..514646390 --- /dev/null +++ b/src/components/left/settings/SettingsPrivacyPublicProfilePhoto.tsx @@ -0,0 +1,101 @@ +import React, { + memo, useCallback, useEffect, useRef, +} from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; + +import type { FC } from '../../../lib/teact/teact'; +import type { ApiUser } from '../../../api/types'; + +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; + +import ListItem from '../../ui/ListItem'; +import SelectAvatar from '../../ui/SelectAvatar'; +import Avatar from '../../common/Avatar'; +import ConfirmDialog from '../../ui/ConfirmDialog'; + +import styles from './SettingsPrivacyPublicPhoto.module.scss'; + +type OwnProps = { + currentUser: ApiUser; +}; + +const SettingsPrivacyPublicProfilePhoto: FC = ({ + currentUser, +}) => { + const { + loadFullUser, uploadProfilePhoto, deleteProfilePhoto, showNotification, + } = getActions(); + + const lang = useLang(); + + const fallbackPhoto = currentUser.fullInfo?.fallbackPhoto; + const [isDeleteFallbackPhotoModalOpen, openDeleteFallbackPhotoModal, closeDeleteFallbackPhotoModal] = useFlag(false); + + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + + useEffect(() => { + if (!currentUser.fullInfo) { + loadFullUser({ userId: currentUser.id }); + } + }, [currentUser.fullInfo, currentUser.id, loadFullUser]); + + const handleSelectFile = useCallback((file: File) => { + uploadProfilePhoto({ + file, + isFallback: true, + }); + showNotification({ + message: lang('Privacy.ProfilePhoto.PublicPhotoSuccess'), + }); + }, [lang, showNotification, uploadProfilePhoto]); + + const handleConfirmDelete = useCallback(() => { + closeDeleteFallbackPhotoModal(); + deleteProfilePhoto({ photo: fallbackPhoto! }); + }, [closeDeleteFallbackPhotoModal, deleteProfilePhoto, fallbackPhoto]); + + const handleOpenFileSelector = useCallback(() => { + inputRef.current?.click(); + }, []); + + return ( +
+ + + {lang(fallbackPhoto ? 'Privacy.ProfilePhoto.UpdatePublicPhoto' : 'Privacy.ProfilePhoto.SetPublicPhoto')} + + {fallbackPhoto && ( + } + onClick={openDeleteFallbackPhotoModal} + destructive + > + {lang(fallbackPhoto.isVideo + ? 'Privacy.ProfilePhoto.RemovePublicVideo' + : 'Privacy.ProfilePhoto.RemovePublicPhoto')} + + + )} +

+ {lang('Privacy.ProfilePhoto.PublicPhotoInfo')} +

+
+ ); +}; + +export default memo(SettingsPrivacyPublicProfilePhoto); diff --git a/src/components/left/settings/SettingsPrivacyVisibility.tsx b/src/components/left/settings/SettingsPrivacyVisibility.tsx index 6eb564ae2..9a3708046 100644 --- a/src/components/left/settings/SettingsPrivacyVisibility.tsx +++ b/src/components/left/settings/SettingsPrivacyVisibility.tsx @@ -6,12 +6,14 @@ import type { ApiChat, ApiUser } from '../../../api/types'; import type { ApiPrivacySettings } from '../../../types'; import { SettingsScreens } from '../../../types'; +import { getPrivacyKey } from './helpers/privacy'; +import { selectUser } from '../../../global/selectors'; import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; import ListItem from '../../ui/ListItem'; import RadioGroup from '../../ui/RadioGroup'; -import { getPrivacyKey } from './helpers/privacy'; +import SettingsPrivacyPublicProfilePhoto from './SettingsPrivacyPublicProfilePhoto'; type OwnProps = { screen: SettingsScreens; @@ -24,6 +26,7 @@ type StateProps = Partial & { chatsById?: Record; usersById?: Record; + currentUser: ApiUser; }; const SettingsPrivacyVisibility: FC = ({ @@ -37,6 +40,7 @@ const SettingsPrivacyVisibility: FC = ({ blockUserIds, blockChatIds, chatsById, + currentUser, }) => { const { setPrivacyVisibility } = getActions(); @@ -44,7 +48,6 @@ const SettingsPrivacyVisibility: FC = ({ const visibilityOptions = useMemo(() => { switch (screen) { - case SettingsScreens.PrivacyProfilePhoto: case SettingsScreens.PrivacyGroupChats: return [ { value: 'everybody', label: lang('P2PEverybody') }, @@ -226,6 +229,10 @@ const SettingsPrivacyVisibility: FC = ({ )}
+ + {screen === SettingsScreens.PrivacyProfilePhoto && exceptionLists.shouldShowAllowed && ( + + )} ); }; @@ -239,6 +246,8 @@ export default memo(withGlobal( settings: { privacy }, } = global; + const currentUser = selectUser(global, global.currentUserId!)!; + switch (screen) { case SettingsScreens.PrivacyPhoneNumber: privacySettings = privacy.phoneNumber; @@ -274,12 +283,15 @@ export default memo(withGlobal( } if (!privacySettings) { - return {}; + return { + currentUser, + }; } return { ...privacySettings, chatsById, + currentUser, }; }, )(SettingsPrivacyVisibility)); diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 4cc08f2fc..81b4cd77c 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -11,7 +11,7 @@ import { MediaViewerOrigin } from '../../types'; import { getActions, withGlobal } from '../../global'; import { - getChatMediaMessageIds, isChatAdmin, + getChatMediaMessageIds, isChatAdmin, isUserId, } from '../../global/helpers'; import { selectChat, @@ -288,6 +288,8 @@ const MediaViewer: FC = ({ key={mediaId} chatId={avatarOwner.id} isAvatar + isFallbackAvatar={isUserId(avatarOwner.id) + && (avatarOwner as ApiUser).photos?.[mediaId!].id === (avatarOwner as ApiUser).fullInfo?.fallbackPhoto?.id} /> ) : ( = ({ chatId, messageId, sender, + isFallbackAvatar, isAvatar, message, animationLevel, @@ -85,7 +87,7 @@ const SenderInfo: FC = ({
{isAvatar - ? lang('lng_mediaview_profile_photo') + ? lang(isFallbackAvatar ? 'lng_mediaview_profile_public_photo' : 'lng_mediaview_profile_photo') : formatMediaDateTime(lang, message!.date * 1000, true)}
diff --git a/src/components/ui/AvatarEditable.tsx b/src/components/ui/AvatarEditable.tsx index bdff3f2e7..55d08b519 100644 --- a/src/components/ui/AvatarEditable.tsx +++ b/src/components/ui/AvatarEditable.tsx @@ -35,7 +35,7 @@ const AvatarEditable: FC = ({ function handleSelectFile(event: ChangeEvent) { const target = event.target as HTMLInputElement; - if (!target || !target.files || !target.files[0]) { + if (!target?.files?.[0]) { return; } diff --git a/src/components/ui/SelectAvatar.module.scss b/src/components/ui/SelectAvatar.module.scss new file mode 100644 index 000000000..a48ce97be --- /dev/null +++ b/src/components/ui/SelectAvatar.module.scss @@ -0,0 +1,3 @@ +.input { + display: none; +} diff --git a/src/components/ui/SelectAvatar.tsx b/src/components/ui/SelectAvatar.tsx new file mode 100644 index 000000000..80bef2498 --- /dev/null +++ b/src/components/ui/SelectAvatar.tsx @@ -0,0 +1,55 @@ +import type { ChangeEvent, RefObject } from 'react'; +import React, { memo, useCallback, useState } from '../../lib/teact/teact'; + +import type { FC } from '../../lib/teact/teact'; + +import CropModal from './CropModal'; + +import styles from './SelectAvatar.module.scss'; + +type OwnProps = { + onChange: (file: File) => void; + inputRef: RefObject; +}; + +const SelectAvatar: FC = ({ + onChange, + inputRef, +}) => { + const [selectedFile, setSelectedFile] = useState(); + + function handleSelectFile(event: ChangeEvent) { + const target = event.target as HTMLInputElement; + + if (!target?.files?.[0]) { + return; + } + + setSelectedFile(target.files[0]); + target.value = ''; + } + + const handleAvatarCrop = useCallback((croppedImg: File) => { + setSelectedFile(undefined); + onChange(croppedImg); + }, [onChange]); + + const handleModalClose = useCallback(() => { + setSelectedFile(undefined); + }, []); + + return ( + <> + + + + ); +}; + +export default memo(SelectAvatar); diff --git a/src/global/actions/api/initial.ts b/src/global/actions/api/initial.ts index 3b0026851..74dfa0fb2 100644 --- a/src/global/actions/api/initial.ts +++ b/src/global/actions/api/initial.ts @@ -92,14 +92,16 @@ addActionHandler('setAuthPassword', (global, actions, payload) => { }); addActionHandler('uploadProfilePhoto', async (global, actions, payload) => { - const { file } = payload!; + const { file, isFallback } = payload!; - const result = await callApi('uploadProfilePhoto', file); + const result = await callApi('uploadProfilePhoto', file, isFallback); if (!result) return; global = getGlobal(); global = addUsers(global, buildCollectionByKey(result.users, 'id')); setGlobal(global); + + actions.loadFullUser({ userId: global.currentUserId! }); }); addActionHandler('signUp', (global, actions, payload) => { diff --git a/src/global/actions/api/settings.ts b/src/global/actions/api/settings.ts index ecd338ce7..c8495523c 100644 --- a/src/global/actions/api/settings.ts +++ b/src/global/actions/api/settings.ts @@ -96,7 +96,7 @@ addActionHandler('updateProfile', async (global, actions, payload) => { }); addActionHandler('updateProfilePhoto', async (global, actions, payload) => { - const { photo } = payload; + const { photo, isFallback } = payload; const { currentUserId } = global; if (!currentUserId) return; const currentUser = selectChat(global, currentUserId); @@ -108,7 +108,7 @@ addActionHandler('updateProfilePhoto', async (global, actions, payload) => { profilePhoto: undefined, }, })); - const result = await callApi('updateProfilePhoto', photo); + const result = await callApi('updateProfilePhoto', photo, isFallback); if (!result) return; const { photo: newPhoto, users } = result; diff --git a/src/global/actions/api/users.ts b/src/global/actions/api/users.ts index f12227fbe..1a52b9c3f 100644 --- a/src/global/actions/api/users.ts +++ b/src/global/actions/api/users.ts @@ -29,7 +29,7 @@ import * as langProvider from '../../../util/langProvider'; const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min const runThrottledForSearch = throttle((cb) => cb(), 500, false); -addActionHandler('loadFullUser', (global, actions, payload) => { +addActionHandler('loadFullUser', async (global, actions, payload) => { const { userId } = payload!; const user = selectUser(global, userId); if (!user) { @@ -37,7 +37,12 @@ addActionHandler('loadFullUser', (global, actions, payload) => { } const { id, accessHash } = user; - callApi('fetchFullUser', { id, accessHash }); + const newUser = await callApi('fetchFullUser', { id, accessHash }); + if (!newUser) return; + + if (user.avatarHash !== newUser.avatarHash && user.photos?.length) { + actions.loadProfilePhotos({ profileId: userId }); + } }); addActionHandler('loadUser', async (global, actions, payload) => { @@ -238,12 +243,18 @@ addActionHandler('loadProfilePhotos', async (global, actions, payload) => { const { profileId } = payload!; const isPrivate = isUserId(profileId); - const user = isPrivate ? selectUser(global, profileId) : undefined; + let user = isPrivate ? selectUser(global, profileId) : undefined; const chat = !isPrivate ? selectChat(global, profileId) : undefined; if (!user && !chat) { return; } + if (user && !user?.fullInfo) { + const { id, accessHash } = user; + user = await callApi('fetchFullUser', { id, accessHash }); + if (!user) return; + } + const result = await callApi('fetchProfilePhotos', user, chat); if (!result || !result.photos) { return; @@ -254,6 +265,8 @@ addActionHandler('loadProfilePhotos', async (global, actions, payload) => { const userOrChat = user || chat; const { photos, users } = result; photos.sort((a) => (a.id === userOrChat?.avatarHash ? -1 : 1)); + const fallbackPhoto = user?.fullInfo?.fallbackPhoto; + if (fallbackPhoto) photos.push(fallbackPhoto); global = addUsers(global, buildCollectionByKey(users, 'id')); diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index ccc6ea661..b1ade130b 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -3,7 +3,7 @@ import type { ApiUser, ApiChatBannedRights, ApiChatAdminRights, - ApiChatFolder, ApiTopic, + ApiChatFolder, ApiTopic, ApiUserFullInfo, } from '../../api/types'; import { MAIN_THREAD_ID, @@ -124,8 +124,9 @@ export function getChatAvatarHash( owner: ApiChat | ApiUser, size: 'normal' | 'big' = 'normal', type: 'photo' | 'video' = 'photo', + avatarHash = owner.avatarHash, ) { - if (!owner.avatarHash) { + if (!avatarHash) { return undefined; } const { fullInfo } = owner; @@ -134,14 +135,18 @@ export function getChatAvatarHash( 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); + } return undefined; } switch (size) { case 'big': - return `profile${owner.id}?${owner.avatarHash}`; + return `profile${owner.id}?${avatarHash}`; default: - return `avatar${owner.id}?${owner.avatarHash}`; + return `avatar${owner.id}?${avatarHash}`; } } diff --git a/src/global/types.ts b/src/global/types.ts index 0105e25db..1627ea812 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -984,6 +984,7 @@ export interface ActionPayloads { }; updateProfilePhoto: { photo: ApiPhoto; + isFallback?: boolean; }; // Forwards