Settings / Privacy: Public Profile Pictures (#2309)

This commit is contained in:
Alexander Zinchuk 2023-01-28 02:15:05 +01:00
parent 3dab7609e3
commit c1f3685217
22 changed files with 317 additions and 22 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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,
},
});

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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<OwnProps & StateProps> = ({
setCurrentPhotoIndex(currentPhotoIndex + 1);
}, [currentPhotoIndex, isLast]);
function handleSelectFallbackPhoto() {
if (!isFirst) return;
setHasSlideAnimation(true);
setCurrentPhotoIndex(photos.length - 1);
}
// Swipe gestures
useEffect(() => {
const element = document.querySelector<HTMLDivElement>(`.${styles.photoWrapper}`);
@ -251,6 +258,24 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
>
<div className={styles.photoWrapper}>
{renderPhotoTabs()}
{forceShowSelf && user?.fullInfo?.fallbackPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
(isFirst || isLast) && styles.fallbackPhotoVisible,
)}
>
<div className={styles.fallbackPhotoContents} onClick={handleSelectFallbackPhoto}>
{!isLast && (
<Avatar
photo={user.fullInfo.fallbackPhoto}
className={styles.fallbackPhotoAvatar}
size="mini"
/>
)}
{lang(user.fullInfo.fallbackPhoto.isVideo ? 'UserInfo.PublicVideo' : 'UserInfo.PublicPhoto')}
</div>
</div>
)}
<Transition activeKey={currentPhotoIndex} name={slideAnimation}>
{renderPhoto}
</Transition>

View File

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

View File

@ -0,0 +1,4 @@
.fallback-photo {
margin-right: 2rem;
transform: scale(1.25);
}

View File

@ -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<OwnProps> = ({
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<HTMLInputElement>(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 (
<div className="settings-item">
<ListItem
icon="camera-add"
onClick={handleOpenFileSelector}
>
<SelectAvatar
onChange={handleSelectFile}
inputRef={inputRef}
/>
{lang(fallbackPhoto ? 'Privacy.ProfilePhoto.UpdatePublicPhoto' : 'Privacy.ProfilePhoto.SetPublicPhoto')}
</ListItem>
{fallbackPhoto && (
<ListItem
leftElement={<Avatar photo={fallbackPhoto} size="mini" className={styles.fallbackPhoto} />}
onClick={openDeleteFallbackPhotoModal}
destructive
>
{lang(fallbackPhoto.isVideo
? 'Privacy.ProfilePhoto.RemovePublicVideo'
: 'Privacy.ProfilePhoto.RemovePublicPhoto')}
<ConfirmDialog
isOpen={isDeleteFallbackPhotoModalOpen}
onClose={closeDeleteFallbackPhotoModal}
text={lang('Privacy.ResetPhoto.Confirm')}
confirmLabel={lang('Delete')}
confirmHandler={handleConfirmDelete}
confirmIsDestructive
/>
</ListItem>
)}
<p className="settings-item-description-larger" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('Privacy.ProfilePhoto.PublicPhotoInfo')}
</p>
</div>
);
};
export default memo(SettingsPrivacyPublicProfilePhoto);

View File

@ -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<ApiPrivacySettings> & {
chatsById?: Record<string, ApiChat>;
usersById?: Record<string, ApiUser>;
currentUser: ApiUser;
};
const SettingsPrivacyVisibility: FC<OwnProps & StateProps> = ({
@ -37,6 +40,7 @@ const SettingsPrivacyVisibility: FC<OwnProps & StateProps> = ({
blockUserIds,
blockChatIds,
chatsById,
currentUser,
}) => {
const { setPrivacyVisibility } = getActions();
@ -44,7 +48,6 @@ const SettingsPrivacyVisibility: FC<OwnProps & StateProps> = ({
const visibilityOptions = useMemo(() => {
switch (screen) {
case SettingsScreens.PrivacyProfilePhoto:
case SettingsScreens.PrivacyGroupChats:
return [
{ value: 'everybody', label: lang('P2PEverybody') },
@ -226,6 +229,10 @@ const SettingsPrivacyVisibility: FC<OwnProps & StateProps> = ({
</ListItem>
)}
</div>
{screen === SettingsScreens.PrivacyProfilePhoto && exceptionLists.shouldShowAllowed && (
<SettingsPrivacyPublicProfilePhoto currentUser={currentUser} />
)}
</div>
);
};
@ -239,6 +246,8 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
}
if (!privacySettings) {
return {};
return {
currentUser,
};
}
return {
...privacySettings,
chatsById,
currentUser,
};
},
)(SettingsPrivacyVisibility));

View File

@ -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<StateProps> = ({
key={mediaId}
chatId={avatarOwner.id}
isAvatar
isFallbackAvatar={isUserId(avatarOwner.id)
&& (avatarOwner as ApiUser).photos?.[mediaId!].id === (avatarOwner as ApiUser).fullInfo?.fallbackPhoto?.id}
/>
) : (
<SenderInfo

View File

@ -25,6 +25,7 @@ type OwnProps = {
chatId?: string;
messageId?: number;
isAvatar?: boolean;
isFallbackAvatar?: boolean;
};
type StateProps = {
@ -39,6 +40,7 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
chatId,
messageId,
sender,
isFallbackAvatar,
isAvatar,
message,
animationLevel,
@ -85,7 +87,7 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
</div>
<div className="date" dir="auto">
{isAvatar
? lang('lng_mediaview_profile_photo')
? lang(isFallbackAvatar ? 'lng_mediaview_profile_public_photo' : 'lng_mediaview_profile_photo')
: formatMediaDateTime(lang, message!.date * 1000, true)}
</div>
</div>

View File

@ -35,7 +35,7 @@ const AvatarEditable: FC<OwnProps> = ({
function handleSelectFile(event: ChangeEvent<HTMLInputElement>) {
const target = event.target as HTMLInputElement;
if (!target || !target.files || !target.files[0]) {
if (!target?.files?.[0]) {
return;
}

View File

@ -0,0 +1,3 @@
.input {
display: none;
}

View File

@ -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<HTMLInputElement>;
};
const SelectAvatar: FC<OwnProps> = ({
onChange,
inputRef,
}) => {
const [selectedFile, setSelectedFile] = useState<File | undefined>();
function handleSelectFile(event: ChangeEvent<HTMLInputElement>) {
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 (
<>
<input
type="file"
onChange={handleSelectFile}
accept="image/png, image/jpeg"
ref={inputRef}
className={styles.input}
/>
<CropModal file={selectedFile} onClose={handleModalClose} onChange={handleAvatarCrop} />
</>
);
};
export default memo(SelectAvatar);

View File

@ -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) => {

View File

@ -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;

View File

@ -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'));

View File

@ -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}`;
}
}

View File

@ -984,6 +984,7 @@ export interface ActionPayloads {
};
updateProfilePhoto: {
photo: ApiPhoto;
isFallback?: boolean;
};
// Forwards