Avatar: Display more than 40 profile picture (#4780)

This commit is contained in:
zubiden 2024-08-06 20:06:18 +02:00 committed by Alexander Zinchuk
parent 8ced8b3b05
commit 43244b6e61
37 changed files with 492 additions and 296 deletions

View File

@ -24,7 +24,7 @@ import type {
import { omitUndefined, pick, pickTruthy } from '../../../util/iteratees';
import { getServerTime, getServerTimeOffset } from '../../../util/serverTime';
import { serializeBytes } from '../helpers';
import { buildApiUsernames } from './common';
import { buildApiUsernames, buildAvatarPhotoId } from './common';
import { omitVirtualClassFields } from './helpers';
import {
buildApiEmojiStatus,
@ -50,7 +50,7 @@ function buildApiChatFieldsFromPeerEntity(
const accessHash = ('accessHash' in peerEntity) ? String(peerEntity.accessHash) : undefined;
const hasVideoAvatar = 'photo' in peerEntity && peerEntity.photo && 'hasVideo' in peerEntity.photo
&& peerEntity.photo.hasVideo;
const avatarHash = ('photo' in peerEntity) && peerEntity.photo ? buildAvatarHash(peerEntity.photo) : undefined;
const avatarPhotoId = ('photo' in peerEntity) && peerEntity.photo ? buildAvatarPhotoId(peerEntity.photo) : undefined;
const isSignaturesShown = Boolean('signatures' in peerEntity && peerEntity.signatures);
const hasPrivateLink = Boolean('hasLink' in peerEntity && peerEntity.hasLink);
const isScam = Boolean('scam' in peerEntity && peerEntity.scam);
@ -74,7 +74,7 @@ function buildApiChatFieldsFromPeerEntity(
usernames,
accessHash,
hasVideoAvatar,
avatarHash,
avatarPhotoId,
...('verified' in peerEntity && { isVerified: peerEntity.verified }),
...('callActive' in peerEntity && { isCallActive: peerEntity.callActive }),
...('callNotEmpty' in peerEntity && { isCallNotEmpty: peerEntity.callNotEmpty }),
@ -308,14 +308,6 @@ function getUserName(user: GramJs.User) {
: (user.lastName || '');
}
export function buildAvatarHash(photo: GramJs.TypeUserProfilePhoto | GramJs.TypeChatPhoto) {
if ('photoId' in photo) {
return String(photo.photoId);
}
return undefined;
}
export function buildChatMember(
member: GramJs.TypeChatParticipant | GramJs.TypeChannelParticipant,
): ApiChatMember | undefined {

View File

@ -277,3 +277,11 @@ export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMess
length,
};
}
export function buildAvatarPhotoId(photo: GramJs.TypeUserProfilePhoto | GramJs.TypeChatPhoto) {
if ('photoId' in photo) {
return photo.photoId.toString();
}
return undefined;
}

View File

@ -14,8 +14,7 @@ import type {
StatisticsStoryInteractionCounter,
} from '../../types';
import { buildAvatarHash } from './chats';
import { buildApiUsernames } from './common';
import { buildApiUsernames, buildAvatarPhotoId } from './common';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
export function buildChannelStatistics(stats: GramJs.stats.BroadcastStats): ApiChannelStatistics {
@ -233,6 +232,8 @@ function getOverviewPeriod(data: GramJs.StatsDateRangeDays): StatisticsOverviewP
function buildApiMessagePublicForward(message: GramJs.TypeMessage, chats: GramJs.TypeChat[]): ApiMessagePublicForward {
const peerId = getApiChatIdFromMtpPeer(message.peerId!);
const channel = chats.find((c) => buildApiPeerId(c.id, 'channel') === peerId);
const channelProfilePhoto = channel && 'photo' in channel && channel.photo instanceof GramJs.Photo
? channel.photo : undefined;
return {
messageId: message.id,
@ -243,9 +244,8 @@ function buildApiMessagePublicForward(message: GramJs.TypeMessage, chats: GramJs
type: 'chatTypeChannel',
title: (channel as GramJs.Channel).title,
usernames: buildApiUsernames(channel as GramJs.Channel),
avatarHash: channel && 'photo' in channel
? buildAvatarHash((channel as GramJs.Channel).photo)
: undefined,
avatarPhotoId: channelProfilePhoto && buildAvatarPhotoId(channelProfilePhoto),
hasVideoAvatar: Boolean(channelProfilePhoto?.videoSizes),
},
};
}

View File

@ -11,7 +11,7 @@ import type {
import { buildApiBotInfo } from './bots';
import { buildApiBusinessIntro, buildApiBusinessLocation, buildApiBusinessWorkHours } from './business';
import { buildApiPhoto, buildApiUsernames } from './common';
import { buildApiPhoto, buildApiUsernames, buildAvatarPhotoId } from './common';
import { omitVirtualClassFields } from './helpers';
import { buildApiEmojiStatus, buildApiPeerColor, buildApiPeerId } from './peers';
@ -61,12 +61,8 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
const {
id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId,
} = mtpUser;
const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto
? Boolean(mtpUser.photo.hasVideo)
: undefined;
const avatarHash = mtpUser.photo instanceof GramJs.UserProfilePhoto
? String(mtpUser.photo.photoId)
: undefined;
const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto ? Boolean(mtpUser.photo.hasVideo) : undefined;
const avatarPhotoId = mtpUser.photo && buildAvatarPhotoId(mtpUser.photo);
const userType = buildApiUserType(mtpUser);
const usernames = buildApiUsernames(mtpUser);
const emojiStatus = mtpUser.emojiStatus ? buildApiEmojiStatus(mtpUser.emojiStatus) : undefined;
@ -90,7 +86,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
phoneNumber: mtpUser.phone || '',
noStatus: !mtpUser.status,
...(mtpUser.accessHash && { accessHash: String(mtpUser.accessHash) }),
...(avatarHash && { avatarHash }),
avatarPhotoId,
emojiStatus,
hasVideoAvatar,
areStoriesHidden: Boolean(mtpUser.storiesHidden),

View File

@ -6,7 +6,7 @@ import type {
ApiUser, OnApiUpdate,
} from '../../types';
import { COMMON_CHATS_LIMIT, PROFILE_PHOTOS_LIMIT } from '../../../config';
import { COMMON_CHATS_LIMIT } from '../../../config';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { buildApiPhoto } from '../apiBuilders/common';
import { buildApiPeerId } from '../apiBuilders/peers';
@ -85,10 +85,7 @@ export async function fetchFullUser({
onUpdate({
'@type': 'updateUser',
id,
user: {
...user,
avatarHash: user?.avatarHash || undefined,
},
user,
fullInfo,
});
@ -255,14 +252,24 @@ export async function deleteContact({
});
}
export async function fetchProfilePhotos(user?: ApiUser, chat?: ApiChat) {
export async function fetchProfilePhotos({
peer,
offset = 0,
limit = 0,
}: {
peer: ApiPeer;
offset?: number;
limit?: number;
}) {
const chat = 'title' in peer ? peer as ApiChat : undefined;
const user = !chat ? peer as ApiUser : undefined;
if (user) {
const { id, accessHash } = user;
const result = await invokeRequest(new GramJs.photos.GetUserPhotos({
userId: buildInputEntity(id, accessHash) as GramJs.InputUser,
limit: PROFILE_PHOTOS_LIMIT,
offset: 0,
limit,
offset,
maxId: BigInt('0'),
}));
@ -272,11 +279,17 @@ export async function fetchProfilePhotos(user?: ApiUser, chat?: ApiChat) {
updateLocalDb(result);
const count = result instanceof GramJs.photos.PhotosSlice ? result.count : result.photos.length;
const proposedNextOffsetId = offset + result.photos.length;
const nextOffsetId = proposedNextOffsetId < count ? proposedNextOffsetId : undefined;
return {
count,
photos: result.photos
.filter((photo): photo is GramJs.Photo => photo instanceof GramJs.Photo)
.map((photo) => buildApiPhoto(photo)),
users: result.users.map(buildApiUser).filter(Boolean),
nextOffsetId,
};
}
@ -285,18 +298,22 @@ export async function fetchProfilePhotos(user?: ApiUser, chat?: ApiChat) {
const result = await searchMessagesLocal({
chat: chat!,
type: 'profilePhoto',
limit: PROFILE_PHOTOS_LIMIT,
limit,
});
if (!result) {
return undefined;
}
const { messages, users } = result;
const {
messages, users, totalCount, nextOffsetId,
} = result;
return {
count: totalCount,
photos: messages.map((message) => message.content.action!.photo).filter(Boolean),
users,
nextOffsetId,
};
}

View File

@ -20,12 +20,13 @@ import {
buildApiChatFolder,
buildApiChatFromPreview,
buildApiChatSettings,
buildAvatarHash,
buildChatMember,
buildChatMembers,
buildChatTypingStatus,
} from '../apiBuilders/chats';
import { buildApiPhoto, buildApiUsernames, buildPrivacyRules } from '../apiBuilders/common';
import {
buildApiPhoto, buildApiUsernames, buildPrivacyRules,
} from '../apiBuilders/common';
import { omitVirtualClassFields } from '../apiBuilders/helpers';
import {
buildApiMessageExtendedMediaPreview,
@ -244,8 +245,10 @@ export function updater(update: Update) {
},
});
} else if (action instanceof GramJs.MessageActionChatEditPhoto) {
const apiPhoto = action.photo instanceof GramJs.Photo && buildApiPhoto(action.photo);
if (!apiPhoto) return;
const photo = buildChatPhotoForLocalDb(action.photo);
const avatarHash = buildAvatarHash(photo);
const localDbChatId = resolveMessageApiChatId(update.message)!;
if (localDb.chats[localDbChatId]) {
@ -253,16 +256,11 @@ export function updater(update: Update) {
}
addPhotoToLocalDb(action.photo);
if (avatarHash) {
onUpdate({
'@type': 'updateChat',
id: message.chatId,
chat: {
avatarHash,
},
...(action.photo instanceof GramJs.Photo && { newProfilePhoto: buildApiPhoto(action.photo) }),
});
}
onUpdate({
'@type': 'updateNewProfilePhoto',
peerId: message.chatId,
photo: apiPhoto,
});
} else if (action instanceof GramJs.MessageActionChatDeletePhoto) {
const localDbChatId = resolveMessageApiChatId(update.message)!;
if (localDb.chats[localDbChatId]) {
@ -270,9 +268,8 @@ export function updater(update: Update) {
}
onUpdate({
'@type': 'updateChat',
id: message.chatId,
chat: { avatarHash: undefined },
'@type': 'updateDeleteProfilePhoto',
peerId: message.chatId,
});
} else if (action instanceof GramJs.MessageActionChatDeleteUser) {
// eslint-disable-next-line no-underscore-dangle

View File

@ -3,7 +3,7 @@ import type { ApiBotCommand } from './bots';
import type {
ApiChatReactions, ApiFormattedText, ApiPhoto, ApiStickerSet,
} from './messages';
import type { ApiChatInviteImporter } from './misc';
import type { ApiChatInviteImporter, ApiPeerPhotos } from './misc';
import type {
ApiEmojiStatus, ApiFakeType, ApiUser, ApiUsername,
} from './users';
@ -35,12 +35,12 @@ export interface ApiChat {
accessHash?: string;
isMin?: boolean;
hasVideoAvatar?: boolean;
avatarHash?: string;
avatarPhotoId?: string;
usernames?: ApiUsername[];
membersCount?: number;
creationDate?: number;
isSupport?: true;
photos?: ApiPhoto[];
profilePhotos?: ApiPeerPhotos;
draftDate?: number;
isProtected?: boolean;
fakeType?: ApiFakeType;

View File

@ -277,3 +277,12 @@ export interface ApiCollectionInfo {
purchaseDate: number;
url: string;
}
export interface ApiPeerPhotos {
fallbackPhoto?: ApiPhoto;
personalPhoto?: ApiPhoto;
photos: ApiPhoto[];
count: number;
nextOffset?: number;
isLoading?: boolean;
}

View File

@ -100,7 +100,6 @@ export type ApiUpdateChat = {
'@type': 'updateChat';
id: string;
chat: Partial<ApiChat>;
newProfilePhoto?: ApiPhoto;
noTopChatsRequest?: boolean;
};
@ -742,6 +741,18 @@ export type ApiUpdateStarsBalance = {
balance: number;
};
export type ApiUpdateDeleteProfilePhoto = {
'@type': 'updateDeleteProfilePhoto';
peerId: string;
photoId?: string;
};
export type ApiUpdateNewProfilePhoto = {
'@type': 'updateNewProfilePhoto';
peerId: string;
photo: ApiPhoto;
};
export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
@ -773,7 +784,8 @@ export type ApiUpdate = (
ApiUpdateStealthMode | ApiUpdateAttachMenuBots | ApiUpdateNewAuthorization | ApiUpdateGroupInvitePrivacyForbidden |
ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage |
ApiUpdateDeleteSavedHistory | ApiUpdatePremiumFloodWait | ApiUpdateStarsBalance |
ApiUpdateQuickReplyMessage | ApiUpdateQuickReplies | ApiDeleteQuickReply | ApiUpdateDeleteQuickReplyMessages
ApiUpdateQuickReplyMessage | ApiUpdateQuickReplies | ApiDeleteQuickReply | ApiUpdateDeleteQuickReplyMessages |
ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto
);
export type OnApiUpdate = (update: ApiUpdate) => void;

View File

@ -3,6 +3,7 @@ import type { ApiBotInfo } from './bots';
import type { ApiBusinessIntro, ApiBusinessLocation, ApiBusinessWorkHours } from './business';
import type { ApiPeerColor } from './chats';
import type { ApiDocument, ApiPhoto } from './messages';
import type { ApiPeerPhotos } from './misc';
export interface ApiUser {
id: string;
@ -21,8 +22,8 @@ export interface ApiUser {
phoneNumber: string;
accessHash?: string;
hasVideoAvatar?: boolean;
avatarHash?: string;
photos?: ApiPhoto[];
avatarPhotoId?: string;
profilePhotos?: ApiPeerPhotos;
botPlaceholder?: string;
canBeInvitedToGroup?: boolean;
commonChats?: {

View File

@ -17,7 +17,7 @@ import {
getChatTitle,
getPeerStoryHtmlId,
getUserFullName,
getVideoAvatarMediaHash,
getVideoProfilePhotoMediaHash,
getWebDocumentHash,
isAnonymousForwardsChat,
isChatWithRepliesBot,
@ -121,7 +121,7 @@ const Avatar: FC<OwnProps> = ({
} else if (photo) {
imageHash = `photo${photo.id}?size=m`;
if (photo.isVideo && withVideo) {
videoHash = getVideoAvatarMediaHash(photo);
videoHash = getVideoProfilePhotoMediaHash(photo);
}
} else if (webPhoto) {
imageHash = getWebDocumentHash(webPhoto);

View File

@ -109,7 +109,7 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
const {
loadFullChat,
openMediaViewer,
loadProfilePhotos,
loadMoreProfilePhotos,
} = getActions();
const lang = useOldLang();
@ -121,9 +121,9 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
useEffect(() => {
if (chatId && !isMin) {
if (withFullInfo) loadFullChat({ chatId });
if (withMediaViewer) loadProfilePhotos({ profileId: chatId });
if (withMediaViewer) loadMoreProfilePhotos({ peerId: chatId, isPreload: true });
}
}, [chatId, isMin, withFullInfo, loadFullChat, loadProfilePhotos, isSuperGroup, withMediaViewer]);
}, [chatId, isMin, withFullInfo, isSuperGroup, withMediaViewer]);
const handleAvatarViewerOpen = useLastCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => {

View File

@ -102,7 +102,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
const {
loadFullUser,
openMediaViewer,
loadProfilePhotos,
loadMoreProfilePhotos,
} = getActions();
const lang = useOldLang();
@ -112,7 +112,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
useEffect(() => {
if (userId) {
if (withFullInfo && isSynced) loadFullUser({ userId });
if (withMediaViewer) loadProfilePhotos({ profileId: userId });
if (withMediaViewer) loadMoreProfilePhotos({ peerId: userId, isPreload: true });
}
}, [userId, withFullInfo, withMediaViewer, isSynced]);

View File

@ -3,22 +3,19 @@ import React, { memo, useEffect, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type {
ApiChat, ApiPhoto, ApiSticker, ApiTopic, ApiUser, ApiUserStatus,
ApiChat, ApiSticker, ApiTopic, ApiUser, ApiUserStatus,
} from '../../api/types';
import type { GlobalState } from '../../global/types';
import { MediaViewerOrigin } from '../../types';
import {
getUserStatus, isAnonymousForwardsChat, isChatChannel, isUserId, isUserOnline,
getUserStatus, isAnonymousForwardsChat, isChatChannel, isUserOnline,
} from '../../global/helpers';
import {
selectChat,
selectChatFullInfo,
selectCurrentMessageList,
selectTabState,
selectThreadMessagesCount,
selectUser,
selectUserFullInfo,
selectUserStatus,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
@ -30,7 +27,6 @@ import renderText from './helpers/renderText';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import usePrevious from '../../hooks/usePrevious';
import { useStateRef } from '../../hooks/useStateRef';
import usePhotosPreload from './hooks/usePhotosPreload';
import Transition from '../ui/Transition';
@ -43,7 +39,7 @@ import './ProfileInfo.scss';
import styles from './ProfileInfo.module.scss';
type OwnProps = {
userId: string;
peerId: string;
forceShowSelf?: boolean;
canPlayVideo: boolean;
};
@ -57,16 +53,13 @@ type StateProps =
avatarOwnerId?: string;
topic?: ApiTopic;
messagesCount?: number;
userPersonalPhoto?: ApiPhoto;
userProfilePhoto?: ApiPhoto;
userFallbackPhoto?: ApiPhoto;
chatProfilePhoto?: ApiPhoto;
emojiStatusSticker?: ApiSticker;
}
& Pick<GlobalState, 'isSynced'>;
};
const EMOJI_STATUS_SIZE = 24;
const EMOJI_TOPIC_SIZE = 120;
const LOAD_MORE_THRESHOLD = 3;
const MAX_PHOTO_DASH_COUNT = 30;
const ProfileInfo: FC<OwnProps & StateProps> = ({
forceShowSelf,
@ -74,33 +67,28 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
user,
userStatus,
chat,
isSynced,
mediaIndex,
avatarOwnerId,
topic,
messagesCount,
userPersonalPhoto,
userProfilePhoto,
userFallbackPhoto,
chatProfilePhoto,
emojiStatusSticker,
peerId,
}) => {
const {
loadFullUser,
openMediaViewer,
openPremiumModal,
openStickerSet,
openPrivacySettingsNoticeModal,
loadMoreProfilePhotos,
} = getActions();
const lang = useOldLang();
const { id: userId } = user || {};
const { id: chatId } = chat || {};
const photos = user?.photos || chat?.photos || MEMO_EMPTY_ARRAY;
const userProfilePhotos = user?.profilePhotos;
const chatProfilePhotos = chat?.profilePhotos;
const photos = userProfilePhotos?.photos || chatProfilePhotos?.photos || MEMO_EMPTY_ARRAY;
const prevMediaIndex = usePrevious(mediaIndex);
const prevAvatarOwnerId = usePrevious(avatarOwnerId);
const mediaIndexRef = useStateRef(mediaIndex);
const [hasSlideAnimation, setHasSlideAnimation] = useState(true);
// slideOptimized doesn't work well when animation is dynamically disabled
const slideAnimation = hasSlideAnimation ? (lang.isRtl ? 'slideRtl' : 'slide') : 'none';
@ -109,6 +97,12 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
const isFirst = photos.length <= 1 || currentPhotoIndex === 0;
const isLast = photos.length <= 1 || currentPhotoIndex === photos.length - 1;
useEffect(() => {
if (photos.length - currentPhotoIndex <= LOAD_MORE_THRESHOLD) {
loadMoreProfilePhotos({ peerId });
}
}, [currentPhotoIndex, peerId, photos.length]);
// Set the current avatar photo to the last selected photo in Media Viewer after it is closed
useEffect(() => {
if (prevAvatarOwnerId && prevMediaIndex !== undefined && mediaIndex === undefined) {
@ -117,12 +111,6 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
}
}, [mediaIndex, prevMediaIndex, prevAvatarOwnerId]);
// Reset the current avatar photo to the one selected in Media Viewer if photos have changed
useEffect(() => {
setHasSlideAnimation(false);
setCurrentPhotoIndex(mediaIndexRef.current || 0);
}, [mediaIndexRef, photos]);
// Deleting the last profile photo may result in an error
useEffect(() => {
if (currentPhotoIndex > photos.length) {
@ -131,32 +119,26 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
}
}, [currentPhotoIndex, photos.length]);
useEffect(() => {
if (isSynced && userId && !forceShowSelf) {
loadFullUser({ userId });
}
}, [userId, loadFullUser, isSynced, forceShowSelf]);
usePhotosPreload(photos, currentPhotoIndex);
const handleProfilePhotoClick = useLastCallback(() => {
openMediaViewer({
isAvatarView: true,
chatId: userId || chatId,
chatId: peerId,
mediaIndex: currentPhotoIndex,
origin: forceShowSelf ? MediaViewerOrigin.SettingsAvatar : MediaViewerOrigin.ProfileAvatar,
});
});
const handleStatusClick = useLastCallback(() => {
if (!userId) {
if (!peerId) {
openStickerSet({
stickerSetInfo: emojiStatusSticker!.stickerSetInfo,
});
return;
}
openPremiumModal({ fromUserId: userId });
openPremiumModal({ fromUserId: peerId });
});
const selectPreviousMedia = useLastCallback(() => {
@ -231,14 +213,18 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
}
function renderPhotoTabs() {
if (!photos || photos.length <= 1) {
const totalPhotosLength = Math.max(photos.length, userProfilePhotos?.count || 0, chatProfilePhotos?.count || 0);
if (!photos || totalPhotosLength <= 1) {
return undefined;
}
const enumerator = Array.from({ length: Math.min(totalPhotosLength, MAX_PHOTO_DASH_COUNT) });
const activeDashIndex = currentPhotoIndex >= MAX_PHOTO_DASH_COUNT ? MAX_PHOTO_DASH_COUNT - 1 : currentPhotoIndex;
return (
<div className={styles.photoDashes}>
{photos.map((_, i) => (
<span className={buildClassName(styles.photoDash, i === currentPhotoIndex && styles.photoDash_current)} />
{enumerator.map((_, i) => (
<span className={buildClassName(styles.photoDash, i === activeDashIndex && styles.photoDash_current)} />
))}
</div>
);
@ -248,14 +234,13 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
const photo = photos.length > 0
? photos[currentPhotoIndex]
: undefined;
const profilePhoto = photo || userPersonalPhoto || userProfilePhoto || chatProfilePhoto || userFallbackPhoto;
return (
<ProfilePhoto
key={currentPhotoIndex}
user={user}
chat={chat}
photo={profilePhoto}
photo={photo}
canPlayVideo={Boolean(isActive && canPlayVideo)}
onClick={handleProfilePhotoClick}
/>
@ -263,8 +248,6 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
}
function renderStatus() {
const peerId = (chatId || userId)!;
const isAnonymousForwards = isAnonymousForwardsChat(peerId);
if (isAnonymousForwards) return undefined;
@ -310,18 +293,18 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
>
<div className={styles.photoWrapper}>
{renderPhotoTabs()}
{!forceShowSelf && userPersonalPhoto && (
{!forceShowSelf && userProfilePhotos?.personalPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
isFirst && styles.fallbackPhotoVisible,
)}
>
<div className={styles.fallbackPhotoContents}>
{lang(userPersonalPhoto.isVideo ? 'UserInfo.CustomVideo' : 'UserInfo.CustomPhoto')}
{lang(userProfilePhotos.personalPhoto.isVideo ? 'UserInfo.CustomVideo' : 'UserInfo.CustomPhoto')}
</div>
</div>
)}
{forceShowSelf && userFallbackPhoto && (
{forceShowSelf && userProfilePhotos?.fallbackPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
(isFirst || isLast) && styles.fallbackPhotoVisible,
@ -330,12 +313,12 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
<div className={styles.fallbackPhotoContents} onClick={handleSelectFallbackPhoto}>
{!isLast && (
<Avatar
photo={userFallbackPhoto}
photo={userProfilePhotos.fallbackPhoto}
className={styles.fallbackPhotoAvatar}
size="mini"
/>
)}
{lang(userFallbackPhoto.isVideo ? 'UserInfo.PublicVideo' : 'UserInfo.PublicPhoto')}
{lang(userProfilePhotos.fallbackPhoto.isVideo ? 'UserInfo.PublicVideo' : 'UserInfo.PublicPhoto')}
</div>
</div>
)}
@ -381,37 +364,28 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>(
(global, { userId }): StateProps => {
const { isSynced } = global;
const user = selectUser(global, userId);
const isPrivate = isUserId(userId);
const userStatus = selectUserStatus(global, userId);
const chat = selectChat(global, userId);
(global, { peerId }): StateProps => {
const user = selectUser(global, peerId);
const userStatus = selectUserStatus(global, peerId);
const chat = selectChat(global, peerId);
const { mediaIndex, chatId: avatarOwnerId } = selectTabState(global).mediaViewer;
const isForum = chat?.isForum;
const { threadId: currentTopicId } = selectCurrentMessageList(global) || {};
const topic = isForum && currentTopicId ? chat?.topics?.[currentTopicId] : undefined;
const userFullInfo = isPrivate ? selectUserFullInfo(global, userId) : undefined;
const chatFullInfo = !isPrivate ? selectChatFullInfo(global, userId) : undefined;
const emojiStatus = (user || chat)?.emojiStatus;
const emojiStatusSticker = emojiStatus ? global.customEmojis.byId[emojiStatus.documentId] : undefined;
return {
isSynced,
user,
userStatus,
chat,
userPersonalPhoto: userFullInfo?.personalPhoto,
userProfilePhoto: userFullInfo?.profilePhoto,
userFallbackPhoto: userFullInfo?.fallbackPhoto,
chatProfilePhoto: chatFullInfo?.profilePhoto,
mediaIndex,
avatarOwnerId,
emojiStatusSticker,
...(topic && {
topic,
messagesCount: selectThreadMessagesCount(global, userId, currentTopicId!),
messagesCount: selectThreadMessagesCount(global, peerId, currentTopicId!),
}),
};
},

View File

@ -8,8 +8,10 @@ import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types';
import {
getChatAvatarHash,
getChatTitle,
getPhotoMediaHash,
getProfilePhotoMediaHash,
getUserFullName,
getVideoAvatarMediaHash,
getVideoProfilePhotoMediaHash,
isAnonymousForwardsChat,
isChatWithRepliesBot,
isDeletedUser,
@ -62,28 +64,30 @@ const ProfilePhoto: FC<OwnProps> = ({
const isDeleted = user && isDeletedUser(user);
const isRepliesChat = chat && isChatWithRepliesBot(chat.id);
const isAnonymousForwards = chat && isAnonymousForwardsChat(chat.id);
const peer = user || chat;
const peer = (user || chat)!;
const canHaveMedia = peer && !isSavedMessages && !isDeleted && !isRepliesChat && !isAnonymousForwards;
const { isVideo } = photo || {};
const avatarHash = canHaveMedia && getChatAvatarHash(peer, 'normal');
const avatarBlobUrl = useMedia(avatarHash);
const avatarHash = (!photo || photo.id === peer.avatarPhotoId) && getChatAvatarHash(peer, 'normal');
const photoHash = canHaveMedia && photo && !isVideo && `photo${photo.id}?size=c`;
const previewHash = canHaveMedia && photo && !avatarHash && getPhotoMediaHash(photo, 'pictogram');
const previewBlobUrl = useMedia(previewHash || avatarHash);
const photoHash = canHaveMedia && photo && !isVideo && getProfilePhotoMediaHash(photo);
const photoBlobUrl = useMedia(photoHash);
const videoHash = canHaveMedia && photo && isVideo && getVideoAvatarMediaHash(photo);
const videoHash = canHaveMedia && photo && isVideo && getVideoProfilePhotoMediaHash(photo);
const videoBlobUrl = useMedia(videoHash);
const fullMediaData = videoBlobUrl || photoBlobUrl;
const [isVideoReady, markVideoReady] = useFlag();
const isFullMediaReady = Boolean(fullMediaData && (!isVideo || isVideoReady));
const transitionClassNames = useMediaTransition(isFullMediaReady);
const isBlurredThumb = canHaveMedia && !isFullMediaReady && !avatarBlobUrl && photo?.thumbnail?.dataUri;
const isBlurredThumb = canHaveMedia && !isFullMediaReady && !previewBlobUrl && photo?.thumbnail?.dataUri;
const blurredThumbCanvasRef = useCanvasBlur(
photo?.thumbnail?.dataUri, !isBlurredThumb, isMobile && !IS_CANVAS_FILTER_SUPPORTED,
);
const hasMedia = photo || avatarBlobUrl || isBlurredThumb;
const hasMedia = photo || previewBlobUrl || isBlurredThumb;
useEffect(() => {
if (videoRef.current && !canPlayVideo) {
@ -121,7 +125,7 @@ const ProfilePhoto: FC<OwnProps> = ({
{isBlurredThumb ? (
<canvas ref={blurredThumbCanvasRef} className="thumb canvas-blur-setup" />
) : (
<img src={avatarBlobUrl} draggable={false} className="thumb" alt="" />
<img src={previewBlobUrl} draggable={false} className="thumb" alt="" />
)}
{photo && (
isVideo ? (

View File

@ -3,6 +3,7 @@ import { useEffect } from '../../../lib/teact/teact';
import type { ApiPhoto } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { getProfilePhotoMediaHash } from '../../../global/helpers';
import * as mediaLoader from '../../../util/mediaLoader';
const PHOTOS_TO_PRELOAD = 4;
@ -13,9 +14,10 @@ export default function usePhotosPreload(
) {
useEffect(() => {
photos.slice(currentIndex, currentIndex + PHOTOS_TO_PRELOAD).forEach((photo) => {
const mediaData = mediaLoader.getFromMemory(`photo${photo.id}?size=c`);
const mediaHash = getProfilePhotoMediaHash(photo);
const mediaData = mediaLoader.getFromMemory(mediaHash);
if (!mediaData) {
mediaLoader.fetch(`photo${photo.id}?size=c`, ApiMediaFormat.BlobUrl);
mediaLoader.fetch(mediaHash, ApiMediaFormat.BlobUrl);
}
});
}, [currentIndex, photos]);

View File

@ -49,7 +49,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
onReset,
}) => {
const {
loadProfilePhotos,
loadMoreProfilePhotos,
openPremiumModal,
openSupportChat,
openUrl,
@ -63,9 +63,9 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
useEffect(() => {
if (currentUserId) {
loadProfilePhotos({ profileId: currentUserId });
loadMoreProfilePhotos({ peerId: currentUserId, isPreload: true });
}
}, [currentUserId, loadProfilePhotos]);
}, [currentUserId]);
useHistoryBack({
isActive,
@ -82,7 +82,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
<div className="settings-main-menu">
{currentUserId && (
<ProfileInfo
userId={currentUserId}
peerId={currentUserId}
canPlayVideo={Boolean(isActive)}
forceShowSelf
/>

View File

@ -80,6 +80,7 @@ type StateProps = {
};
const ANIMATION_DURATION = 250;
const AVATAR_LOAD_TRIGGER = 4;
const MediaViewer = ({
chatId,
@ -109,6 +110,7 @@ const MediaViewer = ({
focusMessage,
toggleChatInfo,
searchChatMediaMessages,
loadMoreProfilePhotos,
} = getActions();
const isOpen = Boolean(avatarOwner || message || standaloneMedia);
@ -255,9 +257,17 @@ const MediaViewer = ({
}, [isGif, isVideo]);
const loadMoreItemsIfNeeded = useLastCallback((item?: MediaViewerItem) => {
if (!item || !withDynamicLoading || isLoadingMoreMedia) return;
if (item.type !== 'message') return;
searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: item.message.id });
if (!item || isLoadingMoreMedia) return;
if (item.type === 'avatar') {
const isNearEnd = item.mediaIndex >= item.avatarOwner.profilePhotos!.photos.length - AVATAR_LOAD_TRIGGER;
if (!isNearEnd) return;
loadMoreProfilePhotos({ peerId: item.avatarOwner.id });
}
if (item.type === 'message' && withDynamicLoading) {
searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: item.message.id });
}
});
const getNextItem = useLastCallback((from: MediaViewerItem, direction: number): MediaViewerItem | undefined => {
@ -276,7 +286,7 @@ const MediaViewer = ({
if (from.type === 'avatar') {
const { avatarOwner: fromAvatarOwner, mediaIndex: fromMediaIndex } = from;
const nextIndex = fromMediaIndex + direction;
if (nextIndex >= 0 && fromAvatarOwner.photos && nextIndex < fromAvatarOwner.photos.length) {
if (nextIndex >= 0 && fromAvatarOwner.profilePhotos && nextIndex < fromAvatarOwner.profilePhotos.photos.length) {
return { type: 'avatar', avatarOwner: fromAvatarOwner, mediaIndex: nextIndex };
}
@ -332,7 +342,8 @@ const MediaViewer = ({
});
const handleBeforeDelete = useLastCallback(() => {
const mediaCount = avatarOwner?.photos?.length || standaloneMedia?.length || messageMediaIds?.length || 0;
const mediaCount = avatarOwner?.profilePhotos?.photos.length
|| standaloneMedia?.length || messageMediaIds?.length || 0;
if (mediaCount <= 1 || !currentItem) {
handleClose();
return;
@ -344,7 +355,7 @@ const MediaViewer = ({
return;
}
if (currentItem.type === 'avatar' || currentItem.type === 'standalone') {
if ((currentItem.type === 'avatar' && isUserId(currentItem.avatarOwner.id)) || currentItem.type === 'standalone') {
// Keep current item, it'll update when indexes shift
return;
}
@ -451,9 +462,12 @@ export default memo(withGlobal(
canUpdateMedia = isUserId(peer.id) ? peer.id === currentUserId : isChatAdmin(peer as ApiChat);
}
const profilePhotos = peer?.profilePhotos;
return {
avatar: peer?.photos?.[mediaIndex!],
avatar: profilePhotos?.photos[mediaIndex!],
avatarOwner: peer,
isLoadingMoreMedia: profilePhotos?.isLoading,
isChatWithSelf,
canUpdateMedia,
withAnimation,
@ -462,6 +476,7 @@ export default memo(withGlobal(
isHidden,
standaloneMedia,
mediaIndex,
isSynced,
};
}

View File

@ -129,7 +129,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
const handleUpdate = useLastCallback(() => {
if (item?.type !== 'avatar') return;
const { avatarOwner, mediaIndex } = item;
const avatarPhoto = avatarOwner.photos?.[mediaIndex]!;
const avatarPhoto = avatarOwner.profilePhotos?.photos[mediaIndex]!;
if (isUserId(avatarOwner.id)) {
updateProfilePhoto({ photo: avatarPhoto });
} else {
@ -170,7 +170,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
onClose={closeDeleteModal}
onConfirm={onBeforeDelete}
profileId={item.avatarOwner.id}
photo={item.avatarOwner.photos![item.mediaIndex!]}
photo={item.avatarOwner.profilePhotos!.photos[item.mediaIndex!]}
/>
) : undefined;
}
@ -212,7 +212,8 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
if (item?.type === 'message') {
openDeleteMessageModal({
isSchedule: messageListType === 'scheduled',
message: item.message, onConfirm: onBeforeDelete,
message: item.message,
onConfirm: onBeforeDelete,
});
} else {
openDeleteModal();
@ -389,7 +390,7 @@ export default memo(withGlobal<OwnProps>(
const message = item?.type === 'message' ? item.message : undefined;
const avatarOwner = item?.type === 'avatar' ? item.avatarOwner : undefined;
const avatarPhoto = avatarOwner?.photos?.[item!.mediaIndex!];
const avatarPhoto = avatarOwner?.profilePhotos?.photos[item!.mediaIndex!];
const currentMessageList = selectCurrentMessageList(global);
const { threadId } = selectCurrentMessageList(global) || {};
@ -398,10 +399,10 @@ export default memo(withGlobal<OwnProps>(
const isChatProtected = message && selectIsChatProtected(global, message?.chatId);
const { canDelete: canDeleteMessage } = (threadId
&& message && selectAllowedMessageActions(global, message, threadId)) || {};
const isCurrentAvatar = avatarPhoto && (avatarPhoto.id === avatarOwner?.avatarHash);
const canDeleteAvatar = canUpdateMedia && !!avatarPhoto;
const isCurrentAvatar = avatarPhoto && (avatarPhoto.id === avatarOwner?.avatarPhotoId);
const canDeleteAvatar = canUpdateMedia && Boolean(avatarPhoto);
const canDelete = canDeleteMessage || canDeleteAvatar;
const canUpdate = canUpdateMedia && !!avatarPhoto && !isCurrentAvatar;
const canUpdate = canUpdateMedia && Boolean(avatarPhoto) && !isCurrentAvatar;
const messageListType = currentMessageList?.type;
return {

View File

@ -10,7 +10,6 @@ import {
} from '../../global/helpers';
import {
selectSender,
selectUserFullInfo,
} from '../../global/selectors';
import { formatMediaDateTime } from '../../util/dates/dateFormat';
import renderText from '../common/helpers/renderText';
@ -29,7 +28,6 @@ type OwnProps = {
type StateProps = {
owner?: ApiPeer;
isFallbackAvatar?: boolean;
};
const BULLET = '\u2022';
@ -38,7 +36,6 @@ const ANIMATION_DURATION = 350;
const SenderInfo: FC<OwnProps & StateProps> = ({
owner,
item,
isFallbackAvatar,
}) => {
const {
closeMediaViewer,
@ -71,11 +68,15 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
if (!item || item.type === 'standalone') return undefined;
const avatarOwner = item.type === 'avatar' ? item.avatarOwner : undefined;
const avatar = avatarOwner?.photos?.[item.mediaIndex!];
const avatar = avatarOwner?.profilePhotos?.photos[item.mediaIndex!];
const isFallbackAvatar = avatar?.id === avatarOwner?.profilePhotos?.fallbackPhoto?.id;
const date = item.type === 'message' ? item.message.date : avatar?.date;
if (!date) return undefined;
const formattedDate = formatMediaDateTime(lang, date * 1000, true);
const count = avatarOwner?.profilePhotos?.count
&& (avatarOwner.profilePhotos.count + (avatarOwner?.profilePhotos?.fallbackPhoto ? 1 : 0));
const countText = count && lang('Of', [item.mediaIndex! + 1, count]);
const parts: string[] = [];
if (avatar) {
@ -89,10 +90,12 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
));
}
if (countText) parts.push(countText);
parts.push(formattedDate);
return parts.join(` ${BULLET} `);
}, [item, isFallbackAvatar, lang]);
}, [item, lang]);
if (!owner) {
return undefined;
@ -122,15 +125,8 @@ export default withGlobal<OwnProps>(
const owner = item?.type === 'avatar' ? item.avatarOwner : messageSender;
const fallbackAvatar = item?.type === 'avatar'
? selectUserFullInfo(global, item.avatarOwner.id)?.fallbackPhoto : undefined;
const isFallbackAvatar = fallbackAvatar && item?.type === 'avatar'
&& item.avatarOwner.photos?.[item.mediaIndex].id === fallbackAvatar.id;
return {
owner,
isFallbackAvatar,
};
},
)(SenderInfo);

View File

@ -68,7 +68,7 @@ export default function getViewableMedia(params?: MediaViewerItem): ViewableMedi
}
if (params.type === 'avatar') {
const avatar = params.avatarOwner.photos?.[params.mediaIndex];
const avatar = params.avatarOwner.profilePhotos?.photos[params.mediaIndex];
if (avatar) {
return {
media: avatar,

View File

@ -10,8 +10,9 @@ import {
getMediaHash,
getMediaThumbUri,
getPhotoFullDimensions,
getVideoAvatarMediaHash,
getProfilePhotoMediaHash,
getVideoDimensions,
getVideoProfilePhotoMediaHash,
isDocumentPhoto,
isDocumentVideo,
} from '../../../global/helpers';
@ -36,6 +37,7 @@ export const useMediaProps = ({
origin,
delay,
}: UseMediaProps) => {
const isPhotoAvatar = isAvatar && media?.mediaType === 'photo' && !media.isVideo;
const isVideoAvatar = isAvatar && media?.mediaType === 'photo' && media.isVideo;
const isDocument = media?.mediaType === 'document';
const isVideo = (media?.mediaType === 'video' && !media.isRound) || (isDocument && isDocumentVideo(media));
@ -47,12 +49,16 @@ export const useMediaProps = ({
const getMediaOrAvatarHash = useMemo(() => (isFull?: boolean) => {
if (!media) return undefined;
if ((isPhotoAvatar || isVideoAvatar) && !isFull) {
return getProfilePhotoMediaHash(media);
}
if (isVideoAvatar && isFull) {
return getVideoAvatarMediaHash(media);
return getVideoProfilePhotoMediaHash(media);
}
return getMediaHash(media, isFull ? 'full' : 'preview');
}, [isVideoAvatar, media]);
}, [isVideoAvatar, isPhotoAvatar, media]);
const pictogramBlobUrl = useMedia(
media

View File

@ -7,7 +7,7 @@ import type { TextPart } from '../../types';
import { MAIN_THREAD_ID } from '../../api/types';
import { MediaViewerOrigin, SettingsScreens } from '../../types';
import { getPhotoMediaHash, getVideoAvatarMediaHash } from '../../global/helpers';
import { getPhotoMediaHash, getVideoProfilePhotoMediaHash } from '../../global/helpers';
import { fetchBlob } from '../../util/files';
import useFlag from '../../hooks/useFlag';
@ -39,7 +39,7 @@ const ActionMessageSuggestedAvatar: FC<OwnProps> = ({
const [isVideoModalOpen, openVideoModal, closeVideoModal] = useFlag(false);
const photo = message.content.action!.photo!;
const suggestedPhotoUrl = useMedia(getPhotoMediaHash(photo, 'full'));
const suggestedVideoUrl = useMedia(getVideoAvatarMediaHash(photo));
const suggestedVideoUrl = useMedia(getVideoProfilePhotoMediaHash(photo));
const isVideo = message.content.action!.photo?.isVideo;
const showAvatarNotification = useLastCallback(() => {

View File

@ -204,7 +204,6 @@ const Profile: FC<OwnProps & StateProps> = ({
openMediaViewer,
openAudioPlayer,
focusMessage,
loadProfilePhotos,
setNewChatMembersDialogState,
loadPeerProfileStories,
loadStoriesArchive,
@ -342,10 +341,6 @@ const Profile: FC<OwnProps & StateProps> = ({
setSharedMediaSearchType({ mediaType: tabType as SharedMediaType });
}, [setSharedMediaSearchType, tabType, threadId]);
useEffect(() => {
loadProfilePhotos({ profileId });
}, [profileId]);
const handleSelectMedia = useLastCallback((messageId: number) => {
openMediaViewer({
chatId: profileId,
@ -681,7 +676,7 @@ const Profile: FC<OwnProps & StateProps> = ({
function renderProfileInfo(profileId: string, isReady: boolean, isSavedDialog?: boolean) {
return (
<div className="profile-info">
<ProfileInfo userId={profileId} canPlayVideo={isReady} />
<ProfileInfo peerId={profileId} canPlayVideo={isReady} />
<ChatExtra chatOrUserId={profileId} isSavedDialog={isSavedDialog} />
</div>
);

View File

@ -83,7 +83,6 @@ export const MEMBERS_SLICE = 30;
export const MEMBERS_LOAD_SLICE = 200;
export const PINNED_MESSAGES_LIMIT = 50;
export const BLOCKED_LIST_LIMIT = 100;
export const PROFILE_PHOTOS_LIMIT = 40;
export const PROFILE_SENSITIVE_AREA = 500;
export const TOPIC_LIST_SENSITIVE_AREA = 600;
export const COMMON_CHATS_LIMIT = 100;

View File

@ -63,6 +63,7 @@ import {
addUsers,
addUserStatuses,
deleteChatMessages,
deletePeerPhoto,
deleteTopic,
leaveChat,
removeChatFromChatLists,
@ -574,7 +575,7 @@ addActionHandler('loadFullChat', (global, actions, payload): ActionReturnType =>
const loadChat = async () => {
await loadFullChat(global, actions, chat, tabId);
if (withPhotos) {
actions.loadProfilePhotos({ profileId: chatId });
actions.loadMoreProfilePhotos({ peerId: chatId, shouldInvalidateCache: true });
}
};
@ -1727,17 +1728,12 @@ addActionHandler('updateChatPhoto', async (global, actions, payload): Promise<vo
const { photo, chatId, tabId = getCurrentTabId() } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
global = updateChat(global, chatId, { avatarHash: undefined });
global = updateChatFullInfo(global, chatId, { profilePhoto: undefined });
setGlobal(global);
// This method creates a new entry in photos array
await callApi('editChatPhoto', {
chatId,
accessHash: chat.accessHash,
photo,
});
// Explicitly delete the old photo reference
await callApi('deleteProfilePhotos', [photo]);
actions.loadFullChat({ chatId, tabId, withPhotos: true });
});
@ -1745,35 +1741,22 @@ addActionHandler('deleteChatPhoto', async (global, actions, payload): Promise<vo
const { photo, chatId, tabId = getCurrentTabId() } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
const photosToDelete = [photo];
if (chat.avatarHash === photo.id) {
// Select next photo to set as avatar
const nextPhoto = chat.photos?.[1];
if (nextPhoto) {
photosToDelete.push(nextPhoto);
}
global = updateChat(global, chatId, { avatarHash: undefined });
global = updateChatFullInfo(global, chatId, { profilePhoto: undefined });
setGlobal(global);
// Set next photo as avatar
await callApi('editChatPhoto', {
let isDeleted;
if (photo.id === chat.avatarPhotoId) {
isDeleted = await callApi('editChatPhoto', {
chatId,
accessHash: chat.accessHash,
photo: nextPhoto,
});
} else {
isDeleted = await callApi('deleteProfilePhotos', [photo]);
}
if (!isDeleted) return;
const { photos = [] } = chat;
const newPhotos = photos.filter((p) => photosToDelete.some((toDelete) => toDelete.id !== p.id));
global = getGlobal();
global = updateChat(global, chatId, { photos: newPhotos });
global = deletePeerPhoto(global, chatId, photo.id);
setGlobal(global);
// Delete references to the old photos
const result = await callApi('deleteProfilePhotos', photosToDelete);
if (!result) return;
actions.loadFullChat({ chatId, tabId, withPhotos: true });
});

View File

@ -455,7 +455,7 @@ addActionHandler('uploadContactProfilePhoto', async (global, actions, payload):
return;
}
actions.loadProfilePhotos({ profileId: userId });
actions.loadMoreProfilePhotos({ peerId: userId, shouldInvalidateCache: true });
global = getGlobal();
global = updateManagementProgress(global, ManagementProgress.Complete, tabId);

View File

@ -19,12 +19,13 @@ import { callApi } from '../../../api/gramjs';
import { buildApiInputPrivacyRules } from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import {
addBlockedUser, addNotifyExceptions, addUsers, removeBlockedUser, replaceSettings, updateChat, updateChats,
addBlockedUser, addNotifyExceptions, addUsers, deletePeerPhoto,
removeBlockedUser, replaceSettings, updateChat, updateChats,
updateNotifySettings, updateUser, updateUserFullInfo,
} from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
import {
selectChat, selectTabState, selectUser, selectUserFullInfo,
selectChat, selectTabState, selectUser,
} from '../../selectors';
addActionHandler('updateProfile', async (global, actions, payload): Promise<void> => {
@ -110,7 +111,7 @@ addActionHandler('updateProfilePhoto', async (global, actions, payload): Promise
const currentUser = selectUser(global, currentUserId);
if (!currentUser) return;
global = updateUser(global, currentUserId, { avatarHash: undefined });
global = updateUser(global, currentUserId, { avatarPhotoId: undefined });
global = updateUserFullInfo(global, currentUserId, { profilePhoto: undefined });
setGlobal(global);
@ -129,32 +130,14 @@ addActionHandler('deleteProfilePhoto', async (global, actions, payload): Promise
const { photo } = payload;
const { currentUserId } = global;
if (!currentUserId) return;
const currentUser = selectUser(global, currentUserId);
if (!currentUser) return;
const fullInfo = selectUserFullInfo(global, currentUserId);
if (currentUser.avatarHash === photo.id || fullInfo?.profilePhoto?.id === photo.id) {
global = updateUser(global, currentUserId, { avatarHash: undefined });
global = updateUserFullInfo(global, currentUserId, { profilePhoto: undefined });
}
if (fullInfo?.fallbackPhoto?.id === photo.id) {
global = updateUserFullInfo(global, currentUserId, { fallbackPhoto: undefined });
}
if (fullInfo?.personalPhoto?.id === photo.id) {
global = updateUserFullInfo(global, currentUserId, { personalPhoto: undefined });
}
const { photos = [] } = currentUser;
const newPhotos = photos.filter((p) => p.id !== photo.id);
global = updateUser(global, currentUserId, { photos: newPhotos });
const isDeleted = await callApi('deleteProfilePhotos', [photo]);
if (!isDeleted) return;
global = getGlobal();
global = deletePeerPhoto(global, currentUserId, photo.id);
setGlobal(global);
await callApi('deleteProfilePhotos', [photo]);
actions.loadFullUser({ userId: currentUserId, withPhotos: true });
});

View File

@ -20,9 +20,10 @@ import {
addUserStatuses,
closeNewContactDialog,
replaceUserStatuses,
updateChat,
updateChats,
updateManagementProgress,
updatePeerPhotos,
updatePeerPhotosIsLoading,
updateUser,
updateUserFullInfo,
updateUsers,
@ -30,14 +31,21 @@ import {
updateUserSearchFetchingStatus,
} from '../../reducers';
import {
selectChat, selectChatFullInfo, selectCurrentMessageList, selectPeer, selectTabState, selectUser, selectUserFullInfo,
selectChat,
selectChatFullInfo,
selectCurrentMessageList,
selectPeer,
selectTabState,
selectUser,
selectUserFullInfo,
} from '../../selectors';
const PROFILE_PHOTOS_FIRST_LOAD_LIMIT = 10;
const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min
const runThrottledForSearch = throttle((cb) => cb(), 500, false);
addActionHandler('loadFullUser', async (global, actions, payload): Promise<void> => {
const { userId, withPhotos } = payload!;
const { userId, withPhotos } = payload;
const user = selectUser(global, userId);
if (!user) {
return;
@ -50,11 +58,11 @@ addActionHandler('loadFullUser', async (global, actions, payload): Promise<void>
global = getGlobal();
const fullInfo = selectUserFullInfo(global, userId);
const { user: newUser, fullInfo: newFullInfo } = result;
const hasChangedAvatarHash = user.avatarHash !== newUser.avatarHash;
const hasChangedAvatar = user.avatarPhotoId !== newUser.avatarPhotoId;
const hasChangedProfilePhoto = fullInfo?.profilePhoto?.id !== newFullInfo?.profilePhoto?.id;
const hasChangedFallbackPhoto = fullInfo?.fallbackPhoto?.id !== newFullInfo?.fallbackPhoto?.id;
const hasChangedPersonalPhoto = fullInfo?.personalPhoto?.id !== newFullInfo?.personalPhoto?.id;
const hasChangedPhoto = hasChangedAvatarHash
const hasChangedPhoto = hasChangedAvatar
|| hasChangedProfilePhoto
|| hasChangedFallbackPhoto
|| hasChangedPersonalPhoto;
@ -65,13 +73,13 @@ addActionHandler('loadFullUser', async (global, actions, payload): Promise<void>
global = updateChats(global, buildCollectionByKey(result.chats, 'id'));
setGlobal(global);
if (withPhotos || (user.photos?.length && hasChangedPhoto)) {
actions.loadProfilePhotos({ profileId: userId });
if (withPhotos || (user.profilePhotos?.count && hasChangedPhoto)) {
actions.loadMoreProfilePhotos({ peerId: userId, shouldInvalidateCache: true });
}
});
addActionHandler('loadUser', async (global, actions, payload): Promise<void> => {
const { userId } = payload!;
const { userId } = payload;
const user = selectUser(global, userId);
if (!user) {
return;
@ -251,30 +259,36 @@ addActionHandler('deleteContact', async (global, actions, payload): Promise<void
await callApi('deleteContact', { id, accessHash });
});
addActionHandler('loadProfilePhotos', async (global, actions, payload): Promise<void> => {
const { profileId } = payload!;
const isPrivate = isUserId(profileId);
addActionHandler('loadMoreProfilePhotos', async (global, actions, payload): Promise<void> => {
const { peerId, shouldInvalidateCache, isPreload } = payload;
const isPrivate = isUserId(peerId);
let user = isPrivate ? selectUser(global, profileId) : undefined;
const chat = !isPrivate ? selectChat(global, profileId) : undefined;
if (!user && !chat) {
const user = isPrivate ? selectUser(global, peerId) : undefined;
const chat = !isPrivate ? selectChat(global, peerId) : undefined;
const peer = user || chat;
if (!peer?.avatarPhotoId) {
return;
}
let userFullInfo = selectUserFullInfo(global, profileId);
let chatFullInfo = selectChatFullInfo(global, profileId);
if (user && !userFullInfo?.profilePhoto) {
if (peer.profilePhotos && !shouldInvalidateCache && (isPreload || !peer.profilePhotos.nextOffset)) return;
global = updatePeerPhotosIsLoading(global, peerId, true);
setGlobal(global);
global = getGlobal();
let userFullInfo = selectUserFullInfo(global, peerId);
let chatFullInfo = selectChatFullInfo(global, peerId);
if (user && !userFullInfo) {
const { id, accessHash } = user;
const result = await callApi('fetchFullUser', { id, accessHash });
if (!result?.user) {
return;
}
user = result.user;
userFullInfo = result.fullInfo;
}
if (chat && !chatFullInfo?.profilePhoto) {
if (chat && !chatFullInfo) {
const result = await callApi('fetchFullChat', chat);
if (!result?.fullInfo) {
return;
@ -283,38 +297,42 @@ addActionHandler('loadProfilePhotos', async (global, actions, payload): Promise<
chatFullInfo = result.fullInfo;
}
const result = await callApi('fetchProfilePhotos', user, chat);
const peerFullInfo = userFullInfo || chatFullInfo;
if (!peerFullInfo) return;
const offset = peer.profilePhotos?.nextOffset;
const limit = !offset || isPreload || shouldInvalidateCache ? PROFILE_PHOTOS_FIRST_LOAD_LIMIT : undefined;
const result = await callApi('fetchProfilePhotos', {
peer,
offset,
limit,
});
if (!result || !result.photos) {
return;
}
global = getGlobal();
const userOrChat = user || chat;
const { photos, users } = result;
const fallbackPhoto = userFullInfo?.fallbackPhoto;
const personalPhoto = userFullInfo?.personalPhoto;
const chatCurrentPhoto = chatFullInfo?.profilePhoto;
if (fallbackPhoto) photos.push(fallbackPhoto);
if (personalPhoto) photos.unshift(personalPhoto);
if (chatCurrentPhoto && photos[0]?.id !== chatCurrentPhoto.id) photos.unshift(chatCurrentPhoto);
photos.sort((a) => (a.id === userOrChat?.avatarHash ? -1 : 1));
const {
photos, users, count, nextOffsetId,
} = result;
global = addUsers(global, buildCollectionByKey(users, 'id'));
if (isPrivate) {
global = updateUser(global, profileId, { photos });
} else {
global = updateChat(global, profileId, { photos });
}
global = updatePeerPhotos(global, peerId, {
newPhotos: photos,
count,
nextOffset: nextOffsetId,
fullInfo: peerFullInfo,
shouldInvalidateCache,
});
setGlobal(global);
});
addActionHandler('setUserSearchQuery', (global, actions, payload): ActionReturnType => {
const { query, tabId = getCurrentTabId() } = payload!;
const { query, tabId = getCurrentTabId() } = payload;
if (!query) return;
@ -371,7 +389,7 @@ addActionHandler('importContact', async (global, actions, payload): Promise<void
});
addActionHandler('reportSpam', (global, actions, payload): ActionReturnType => {
const { chatId } = payload!;
const { chatId } = payload;
const peer = selectPeer(global, chatId);
if (!peer) {
return;
@ -381,13 +399,13 @@ addActionHandler('reportSpam', (global, actions, payload): ActionReturnType => {
});
addActionHandler('setEmojiStatus', (global, actions, payload): ActionReturnType => {
const { emojiStatus, expires } = payload!;
const { emojiStatus, expires } = payload;
void callApi('updateEmojiStatus', emojiStatus, expires);
});
addActionHandler('saveCloseFriends', async (global, actions, payload): Promise<void> => {
const { userIds } = payload!;
const { userIds } = payload;
const result = await callApi('saveCloseFriends', userIds);
if (!result) {

View File

@ -14,6 +14,7 @@ import {
import {
addUnreadMentions,
deleteChatMessages,
deletePeerPhoto,
leaveChat,
removeUnreadMentions,
replaceThreadParam,
@ -35,6 +36,7 @@ import {
selectCommonBoxChatId,
selectCurrentMessageList,
selectIsChatListed,
selectPeer,
selectTabState,
selectThreadParam,
selectTopicFromMessage,
@ -57,7 +59,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
const localChat = selectChat(global, update.id);
global = updateChat(global, update.id, update.chat, update.newProfilePhoto);
global = updateChat(global, update.id, update.chat);
if (localChat?.areStoriesHidden !== update.chat.areStoriesHidden) {
global = updatePeerStoriesHidden(global, update.id, update.chat.areStoriesHidden || false);
@ -534,6 +536,43 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
isForumAsMessages: isEnabled,
});
setGlobal(global);
break;
}
case 'updateNewProfilePhoto': {
const { peerId, photo } = update;
global = updateChat(global, peerId, {
avatarPhotoId: photo.id,
});
setGlobal(global);
actions.loadMoreProfilePhotos({ peerId, shouldInvalidateCache: true });
break;
}
case 'updateDeleteProfilePhoto': {
const { peerId, photoId } = update;
const peer = selectPeer(global, peerId);
if (!peer) {
return undefined;
}
if (!photoId || peer.avatarPhotoId === photoId) {
global = updateChat(global, peerId, {
avatarPhotoId: undefined,
profilePhotos: undefined,
});
} else {
global = deletePeerPhoto(global, peerId, photoId);
}
setGlobal(global);
actions.loadMoreProfilePhotos({ peerId, shouldInvalidateCache: true });
break;
}
}

View File

@ -30,6 +30,7 @@ import {
clearMessageTranslation,
deleteChatMessages,
deleteChatScheduledMessages,
deletePeerPhoto,
deleteQuickReply,
deleteQuickReplyMessages,
deleteTopic,
@ -1069,6 +1070,10 @@ export function deleteMessages<T extends GlobalState>(
return;
}
if (message.content.action?.photo) {
global = deletePeerPhoto(global, chatId, message.content.action.photo.id, true);
}
global = updateThreadUnread(global, actions, message, true);
const threadId = selectThreadIdFromMessage(global, message);
@ -1144,6 +1149,10 @@ export function deleteMessages<T extends GlobalState>(
}
}
if (message?.content.action?.photo) {
global = deletePeerPhoto(global, commonBoxChatId, message.content.action.photo.id, true);
}
setTimeout(() => {
global = getGlobal();
global = deleteChatMessages(global, commonBoxChatId, [id]);

View File

@ -104,22 +104,22 @@ export function getChatLink(chat: ApiChat) {
export function getChatAvatarHash(
owner: ApiPeer,
size: 'normal' | 'big' = 'normal',
avatarHash = owner.avatarHash,
avatarPhotoId = owner.avatarPhotoId,
) {
if (!avatarHash) {
if (!avatarPhotoId) {
return undefined;
}
switch (size) {
case 'big':
return `profile${owner.id}?${avatarHash}`;
return `profile${owner.id}?${avatarPhotoId}`;
default:
return `avatar${owner.id}?${avatarHash}`;
return `avatar${owner.id}?${avatarPhotoId}`;
}
}
export function isChatAdmin(chat: ApiChat) {
return Boolean(chat.adminRights);
return Boolean(chat.adminRights || chat.isCreator);
}
export function getHasAdminRight(chat: ApiChat, key: keyof ApiChatAdminRights) {

View File

@ -284,14 +284,18 @@ export function getPhotoMediaHash(photo: ApiPhoto | ApiDocument, target: Target,
case 'preview':
return `${base}?size=${isAction ? 'b' : 'x'}`;
case 'download':
return !isVideo ? base : getVideoAvatarMediaHash(photo);
return !isVideo ? base : getVideoProfilePhotoMediaHash(photo);
case 'full':
default:
return base;
}
}
export function getVideoAvatarMediaHash(photo: ApiPhoto) {
export function getProfilePhotoMediaHash(photo: ApiPhoto) {
return `photo${photo.id}?size=c`;
}
export function getVideoProfilePhotoMediaHash(photo: ApiPhoto) {
if (!photo.isVideo) return undefined;
return `photo${photo.id}?size=u`;
}

View File

@ -1,5 +1,5 @@
import type {
ApiChat, ApiChatFullInfo, ApiChatMember, ApiPhoto, ApiTopic,
ApiChat, ApiChatFullInfo, ApiChatMember, ApiTopic,
} from '../../api/types';
import type { ChatListType, GlobalState } from '../types';
@ -138,12 +138,11 @@ export function removeUnreadMentions<T extends GlobalState>(
}
export function updateChat<T extends GlobalState>(
global: T, chatId: string, chatUpdate: Partial<ApiChat>, photo?: ApiPhoto,
noOmitUnreadReactionCount = false,
global: T, chatId: string, chatUpdate: Partial<ApiChat>, noOmitUnreadReactionCount = false,
): T {
const { byId } = global.chats;
const updatedChat = getUpdatedChat(global, chatId, chatUpdate, photo, noOmitUnreadReactionCount);
const updatedChat = getUpdatedChat(global, chatId, chatUpdate, noOmitUnreadReactionCount);
if (!updatedChat) {
return global;
}
@ -257,7 +256,7 @@ export function addChats<T extends GlobalState>(global: T, newById: Record<strin
// @optimization Don't spread/unspread global for each element, do it in a batch
function getUpdatedChat<T extends GlobalState>(
global: T, chatId: string, chatUpdate: Partial<ApiChat>, photo?: ApiPhoto,
global: T, chatId: string, chatUpdate: Partial<ApiChat>,
noOmitUnreadReactionCount = false,
) {
const { byId } = global.chats;
@ -279,7 +278,6 @@ function getUpdatedChat<T extends GlobalState>(
const updatedChat: ApiChat = {
...chat,
...omit(chatUpdate, omitProps),
...(photo && { photos: [photo, ...(chat.photos || [])] }),
} as ApiChat;
if (!updatedChat.id || !updatedChat.type) {

View File

@ -1,9 +1,11 @@
import type {
ApiChat, ApiChatFullInfo, ApiUser, ApiUserFullInfo,
ApiChat, ApiChatFullInfo, ApiPhoto, ApiUser, ApiUserFullInfo,
} from '../../api/types';
import type { GlobalState } from '../types';
import { isUserId } from '../helpers';
import { uniqueByField } from '../../util/iteratees';
import { isChatChannel, isUserId } from '../helpers';
import { selectChatFullInfo, selectPeer, selectUserFullInfo } from '../selectors';
import { updateChat, updateChatFullInfo } from './chats';
import { updateUser, updateUserFullInfo } from './users';
@ -30,3 +32,137 @@ export function updatePeerFullInfo<T extends GlobalState>(
return updateChatFullInfo(global, peerId, peerFullInfoUpdate);
}
export function updatePeerPhotosIsLoading<T extends GlobalState>(
global: T,
peerId: string,
isLoading: boolean,
) {
const peer = selectPeer(global, peerId);
if (!peer || !peer.profilePhotos) {
return global;
}
return updatePeer(global, peerId, {
profilePhotos: {
...peer.profilePhotos,
isLoading,
},
});
}
export function updatePeerPhotos<T extends GlobalState>(
global: T,
peerId: string,
update: {
newPhotos: ApiPhoto[];
count: number;
nextOffset?: number;
fullInfo: ApiChatFullInfo | ApiUserFullInfo;
shouldInvalidateCache?: boolean;
},
) {
const peer = selectPeer(global, peerId);
if (!peer) {
return global;
}
const {
newPhotos, count, nextOffset, fullInfo, shouldInvalidateCache,
} = update;
const currentPhotos = peer.profilePhotos;
const profilePhoto = fullInfo.profilePhoto;
const fallbackPhoto = 'fallbackPhoto' in fullInfo ? fullInfo.fallbackPhoto : undefined;
const personalPhoto = 'personalPhoto' in fullInfo ? fullInfo.personalPhoto : undefined;
if (!currentPhotos || shouldInvalidateCache) {
// In some channels, last service message with photo change is deleted, so we need to patch it in
if (profilePhoto && profilePhoto.id !== newPhotos[0]?.id) {
newPhotos.unshift(profilePhoto);
}
if (personalPhoto && personalPhoto.id !== newPhotos[0]?.id) {
newPhotos.unshift(personalPhoto);
}
if (fallbackPhoto) {
newPhotos.push(fallbackPhoto);
}
return updatePeer(global, peerId, {
profilePhotos: {
fallbackPhoto,
personalPhoto,
photos: newPhotos,
count,
nextOffset,
isLoading: false,
},
});
}
const hasFallbackPhoto = currentPhotos.photos[currentPhotos.photos.length - 1].id === fallbackPhoto?.id;
const currentPhotoArray = hasFallbackPhoto ? currentPhotos.photos.slice(0, -1) : currentPhotos.photos;
const photos = uniqueByField([...currentPhotoArray, ...newPhotos, fallbackPhoto].filter(Boolean), 'id');
return updatePeer(global, peerId, {
profilePhotos: {
fallbackPhoto,
personalPhoto,
photos,
count,
nextOffset,
isLoading: false,
},
});
}
export function deletePeerPhoto<T extends GlobalState>(
global: T,
peerId: string,
photoId: string,
isFromActionMessage?: boolean,
) {
const peer = selectPeer(global, peerId);
if (!peer || !peer.profilePhotos) {
return global;
}
const isChannel = 'title' in peer && isChatChannel(peer);
const userFullInfo = selectUserFullInfo(global, peerId);
const chatFullInfo = selectChatFullInfo(global, peerId);
const isAvatar = peer.avatarPhotoId === photoId && (!isChannel || isFromActionMessage);
const nextAvatarPhoto = isAvatar ? peer.profilePhotos.photos[1] : undefined;
if (userFullInfo) {
const newFallbackPhoto = userFullInfo.fallbackPhoto?.id === photoId ? undefined : userFullInfo.fallbackPhoto;
const newPersonalPhoto = userFullInfo.personalPhoto?.id === photoId ? undefined : userFullInfo.personalPhoto;
const newProfilePhoto = userFullInfo.profilePhoto?.id === photoId ? nextAvatarPhoto : userFullInfo.profilePhoto;
global = updateUserFullInfo(global, peerId, {
fallbackPhoto: newFallbackPhoto,
personalPhoto: newPersonalPhoto,
profilePhoto: newProfilePhoto,
});
}
if (chatFullInfo) {
const newProfilePhoto = chatFullInfo.profilePhoto?.id === photoId ? nextAvatarPhoto : chatFullInfo.profilePhoto;
global = updateChatFullInfo(global, peerId, {
profilePhoto: newProfilePhoto,
});
}
const avatarPhotoId = isAvatar ? nextAvatarPhoto?.id : peer.avatarPhotoId;
const shouldKeepInPhotos = isAvatar && 'title' in peer && isChatChannel(peer);
const photos = shouldKeepInPhotos
? peer.profilePhotos.photos.filter((photo) => photo.id !== photoId) : peer.profilePhotos.photos.slice();
return updatePeer(global, peerId, {
avatarPhotoId,
profilePhotos: avatarPhotoId ? {
...peer.profilePhotos,
photos,
count: peer.profilePhotos.count - 1,
} : undefined,
});
}

View File

@ -77,5 +77,5 @@ export function addMessageReaction<T extends GlobalState>(
export function updateUnreadReactions<T extends GlobalState>(
global: T, chatId: string, update: Pick<ApiChat, 'unreadReactionsCount' | 'unreadReactions'>,
): T {
return updateChat(global, chatId, update, undefined, true);
return updateChat(global, chatId, update, true);
}

View File

@ -2608,8 +2608,10 @@ export interface ActionPayloads {
isMuted?: boolean;
shouldSharePhoneNumber?: boolean;
} & WithTabId;
loadProfilePhotos: {
profileId: string;
loadMoreProfilePhotos: {
peerId: string;
isPreload?: boolean;
shouldInvalidateCache?: boolean;
};
deleteProfilePhoto: {
photo: ApiPhoto;