2025-10-11 19:10:07 +02:00

652 lines
20 KiB
TypeScript

import { memo, useEffect, useMemo, useState } from '../../../lib/teact/teact';
import type {
ApiChat,
ApiEmojiStatusType,
ApiPeerColorOption,
ApiPeerPhotos,
ApiPeerProfileColorSet,
ApiSavedGifts,
ApiSticker,
ApiTopic,
ApiUser,
ApiUserFullInfo,
ApiUserStatus,
} from '../../../api/types/index';
import type { IconName } from '../../../types/icons/index';
import type { AnimationLevel, ThemeKey } from '../../../types/index';
import { MediaViewerOrigin } from '../../../types/index';
import {
getUserStatus, isAnonymousForwardsChat, isChatChannel, isSystemBot, isUserOnline,
} from '../../../global/helpers/index';
import { getActions, withGlobal } from '../../../global/index';
import {
selectChat,
selectCurrentMessageList,
selectCustomEmoji,
selectPeer,
selectPeerHasProfileBackground,
selectPeerPhotos,
selectPeerProfileColor,
selectPeerSavedGifts,
selectTabState,
selectTheme,
selectThreadMessagesCount,
selectTopic,
selectUser,
selectUserFullInfo,
selectUserStatus,
} from '../../../global/selectors/index';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { captureEvents, SwipeDirection } from '../../../util/captureEvents';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { resolveTransitionName } from '../../../util/resolveTransitionName';
import { REM } from '../helpers/mediaDimensions.ts';
import renderText from '../helpers/renderText.tsx';
import { useVtn } from '../../../hooks/animations/useVtn';
import useIntervalForceUpdate from '../../../hooks/schedulers/useIntervalForceUpdate';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
import useCustomEmoji from '../hooks/useCustomEmoji';
import usePhotosPreload from '../hooks/usePhotosPreload';
import Transition from '../../ui/Transition.tsx';
import Avatar from '../Avatar.tsx';
import FullNameTitle from '../FullNameTitle.tsx';
import Icon from '../icons/Icon.tsx';
import TopicIcon from '../TopicIcon.tsx';
import ProfilePhoto from './ProfilePhoto';
import ProfilePinnedGifts from './ProfilePinnedGifts.tsx';
import RadialPatternBackground from './RadialPatternBackground.tsx';
import './ProfileInfo.scss';
import styles from './ProfileInfo.module.scss';
const MAX_LEVEL_ICON = 90;
type OwnProps = {
isExpanded?: boolean;
peerId: string;
isForSettings?: boolean;
canPlayVideo: boolean;
isForMonoforum?: boolean;
onExpand?: NoneToVoidFunction;
};
type StateProps = {
user?: ApiUser;
userFullInfo?: ApiUserFullInfo;
userStatus?: ApiUserStatus;
chat?: ApiChat;
mediaIndex?: number;
avatarOwnerId?: string;
topic?: ApiTopic;
messagesCount?: number;
animationLevel: AnimationLevel;
emojiStatus?: ApiEmojiStatusType;
emojiStatusSticker?: ApiSticker;
emojiStatusSlug?: string;
profilePhotos?: ApiPeerPhotos;
profileColorOption?: ApiPeerColorOption<ApiPeerProfileColorSet>;
theme: ThemeKey;
isPlain?: boolean;
savedGifts?: ApiSavedGifts;
};
const EMOJI_STATUS_SIZE = 24;
const EMOJI_TOPIC_SIZE = 120;
const LOAD_MORE_THRESHOLD = 3;
const MAX_PHOTO_DASH_COUNT = 30;
const STATUS_UPDATE_INTERVAL = 1000 * 60; // 1 min
const PATTERN_Y_SHIFT = 8 * REM;
const ProfileInfo = ({
isExpanded,
isForSettings,
canPlayVideo,
user,
userFullInfo,
userStatus,
chat,
mediaIndex,
avatarOwnerId,
topic,
messagesCount,
animationLevel,
emojiStatus,
emojiStatusSticker,
emojiStatusSlug,
profilePhotos,
peerId,
isForMonoforum,
profileColorOption,
theme,
isPlain,
savedGifts,
onExpand,
}: OwnProps & StateProps) => {
const {
openMediaViewer,
openPremiumModal,
openStickerSet,
openPrivacySettingsNoticeModal,
loadMoreProfilePhotos,
openUniqueGiftBySlug,
openProfileRatingModal,
loadPeerSavedGifts,
} = getActions();
const oldLang = useOldLang();
const lang = useLang();
useIntervalForceUpdate(user ? STATUS_UPDATE_INTERVAL : undefined);
const { createVtnStyle } = useVtn();
const photos = profilePhotos?.photos || MEMO_EMPTY_ARRAY;
const prevMediaIndex = usePreviousDeprecated(mediaIndex);
const prevAvatarOwnerId = usePreviousDeprecated(avatarOwnerId);
const [hasSlideAnimation, setHasSlideAnimation] = useState(true);
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const isFirst = photos.length <= 1 || currentPhotoIndex === 0;
const isLast = photos.length <= 1 || currentPhotoIndex === photos.length - 1;
const collectibleEmojiStatus = emojiStatus?.type === 'collectible' ? emojiStatus : undefined;
const peer = user || chat;
const { customEmoji: backgroundEmoji } = useCustomEmoji(
collectibleEmojiStatus?.patternDocumentId || peer?.profileColor?.backgroundEmojiId,
);
const profileColorSet = useMemo(() => {
if (collectibleEmojiStatus) {
return {
bgColors: [collectibleEmojiStatus.centerColor, collectibleEmojiStatus.edgeColor],
storyColors: [collectibleEmojiStatus.textColor, collectibleEmojiStatus.textColor],
};
}
const colors = profileColorOption
&& (theme === 'dark' ? profileColorOption.darkColors : profileColorOption.colors);
if (!colors) return undefined;
// Why are they reversed on the server?
return {
bgColors: [...colors.bgColors].reverse(),
storyColors: [...colors.storyColors].reverse(),
};
}, [profileColorOption, theme, collectibleEmojiStatus]);
const pinnedGifts = useMemo(() => {
return savedGifts?.gifts.filter((gift) => {
if (gift.gift.type === 'starGiftUnique') {
return gift.isPinned && gift.gift.slug !== collectibleEmojiStatus?.slug;
}
return gift.isPinned;
});
}, [savedGifts, collectibleEmojiStatus?.slug]);
useEffect(() => {
if (photos.length - currentPhotoIndex <= LOAD_MORE_THRESHOLD) {
loadMoreProfilePhotos({ peerId });
}
}, [currentPhotoIndex, peerId, photos.length]);
useEffect(() => {
loadPeerSavedGifts({ peerId });
}, [peerId]);
// Set the current avatar photo to the last selected photo in Media Viewer after it is closed
useEffect(() => {
if (prevAvatarOwnerId && prevMediaIndex !== undefined && mediaIndex === undefined) {
setHasSlideAnimation(false);
setCurrentPhotoIndex(prevMediaIndex);
}
}, [mediaIndex, prevMediaIndex, prevAvatarOwnerId]);
// Deleting the last profile photo may result in an error
useEffect(() => {
if (currentPhotoIndex > photos.length) {
setHasSlideAnimation(false);
setCurrentPhotoIndex(Math.max(0, photos.length - 1));
}
}, [currentPhotoIndex, photos.length]);
// Reset photo index when collapsing
useEffect(() => {
if (!isExpanded) {
setCurrentPhotoIndex(0);
}
}, [isExpanded]);
usePhotosPreload(photos, currentPhotoIndex);
const handleProfilePhotoClick = useLastCallback(() => {
openMediaViewer({
isAvatarView: true,
chatId: peerId,
mediaIndex: currentPhotoIndex,
origin: isForSettings ? MediaViewerOrigin.SettingsAvatar : MediaViewerOrigin.ProfileAvatar,
});
});
const handleStatusClick = useLastCallback(() => {
if (emojiStatusSlug) {
openUniqueGiftBySlug({ slug: emojiStatusSlug });
return;
}
if (!peerId) {
openStickerSet({
stickerSetInfo: emojiStatusSticker!.stickerSetInfo,
});
return;
}
openPremiumModal({ fromUserId: peerId });
});
const selectPreviousMedia = useLastCallback(() => {
if (isFirst) {
return;
}
setHasSlideAnimation(true);
setCurrentPhotoIndex(currentPhotoIndex - 1);
});
const selectNextMedia = useLastCallback(() => {
if (isLast) {
return;
}
setHasSlideAnimation(true);
setCurrentPhotoIndex(currentPhotoIndex + 1);
});
const handleOpenGetReadDateModal = useLastCallback(() => {
openPrivacySettingsNoticeModal({ chatId: chat!.id, isReadDate: false });
});
const handleRatingClick = useLastCallback((level: number) => {
if (user) {
openProfileRatingModal({ userId: user.id, level });
}
});
const handleMinimizedAvatarClick = useLastCallback(() => {
if (isForSettings) {
handleProfilePhotoClick();
return;
}
onExpand?.();
});
function handleSelectFallbackPhoto() {
if (!isFirst) return;
setHasSlideAnimation(true);
setCurrentPhotoIndex(photos.length - 1);
}
// Swipe gestures
useEffect(() => {
const element = document.querySelector<HTMLDivElement>(`.${styles.photoWrapper}`);
if (!element) {
return undefined;
}
return captureEvents(element, {
selectorToPreventScroll: '.Profile, .settings-content',
onSwipe: IS_TOUCH_ENV ? (e, direction) => {
if (direction === SwipeDirection.Right) {
selectPreviousMedia();
return true;
} else if (direction === SwipeDirection.Left) {
selectNextMedia();
return true;
}
return false;
} : undefined,
});
}, [selectNextMedia, selectPreviousMedia]);
if (!user && !chat) {
return undefined;
}
function renderTopic() {
return (
<div className={styles.topicContainer}>
<TopicIcon
topic={topic!}
size={EMOJI_TOPIC_SIZE}
className={styles.topicIcon}
letterClassName={styles.topicIconTitle}
noLoopLimit
/>
<h3 className={styles.topicTitle} dir={lang.isRtl ? 'rtl' : undefined}>{renderText(topic!.title)}</h3>
<p className={styles.topicMessagesCounter}>
{messagesCount ? oldLang('Chat.Title.Topic', messagesCount, 'i') : oldLang('lng_forum_no_messages')}
</p>
</div>
);
}
function renderPhotoTabs() {
const totalPhotosLength = Math.max(photos.length, profilePhotos?.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}>
{enumerator.map((_, i) => (
<span className={buildClassName(styles.photoDash, i === activeDashIndex && styles.photoDash_current)} />
))}
</div>
);
}
function renderPhoto(isActive?: boolean) {
const photo = photos.length > 0
? photos[currentPhotoIndex]
: undefined;
return (
<ProfilePhoto
key={currentPhotoIndex}
user={user}
chat={chat}
photo={photo}
canPlayVideo={Boolean(isActive && canPlayVideo)}
className={buildClassName(isActive && styles.activeProfilePhoto)}
style={isActive ? createVtnStyle('avatar', true) : undefined}
onClick={handleProfilePhotoClick}
/>
);
}
function renderUserRating() {
if (!userFullInfo?.starsRating) return undefined;
const level = userFullInfo.starsRating.level;
const isNegative = level < 0;
const onRatingClick = () => handleRatingClick(level);
if (isNegative) {
return (
<span role="button" tabIndex={0} className={styles.userRatingNegativeWrapper} onClick={onRatingClick}>
<Icon
name="rating-icons-negative"
className={styles.ratingNegativeIcon}
/>
<span className={styles.ratingLevel}>!</span>
</span>
);
}
const safeLevel = Math.max(level, 1);
const iconLevel = Math.min(safeLevel, MAX_LEVEL_ICON);
const iconName = (iconLevel < 10
? `rating-icons-level${iconLevel}`
: `rating-icons-level${Math.floor(iconLevel / 10) * 10}`) as IconName;
return (
<span role="button" tabIndex={0} className={styles.userRatingWrapper} onClick={onRatingClick}>
<Icon
name={iconName}
className={styles.ratingIcon}
/>
<span className={styles.ratingLevel}>{level}</span>
</span>
);
}
function renderStatus() {
const isAnonymousForwards = isAnonymousForwardsChat(peerId);
const isSystemBotChat = isSystemBot(peerId);
if (isAnonymousForwards || isSystemBotChat) return undefined;
if (isForMonoforum) {
return (
<span
className={buildClassName(styles.status, 'status')}
dir="auto"
style={createVtnStyle('status', true)}
>
{lang('MonoforumStatus')}
</span>
);
}
if (user) {
return (
<div
className={buildClassName(
styles.status,
'status',
isUserOnline(user, userStatus) && 'online',
)}
style={createVtnStyle('status', true)}
>
{renderUserRating()}
<span className={styles.userStatus} dir="auto">
{getUserStatus(oldLang, user, userStatus)}
</span>
{userStatus?.isReadDateRestrictedByMe && (
<span className={styles.getStatus} onClick={handleOpenGetReadDateModal}>
<span>{oldLang('StatusHiddenShow')}</span>
</span>
)}
</div>
);
}
return (
<span
className={buildClassName(styles.status, 'status')}
dir="auto"
style={createVtnStyle('status', true)}
>
{
isChatChannel(chat!)
? oldLang('Subscribers', chat!.membersCount ?? 0, 'i')
: oldLang('Members', chat!.membersCount ?? 0, 'i')
}
</span>
);
}
if (topic) {
return renderTopic();
}
return (
<div
className={buildClassName(
'ProfileInfo',
styles.root,
!isExpanded && styles.minimized,
isPlain && styles.plain,
)}
style={buildStyle(
profileColorSet && `--rating-outline-color: ${isExpanded ? 'transparent' : profileColorSet?.bgColors[1]}`,
profileColorSet && !isExpanded && `--rating-text-color: ${profileColorSet?.bgColors[1]}`,
createVtnStyle('profileInfo', true),
)}
dir={lang.isRtl ? 'rtl' : undefined}
>
{profileColorSet?.bgColors && (
<RadialPatternBackground
backgroundColors={profileColorSet.bgColors}
patternIcon={backgroundEmoji}
patternColor={collectibleEmojiStatus?.patternColor}
patternSize={16}
withLinearGradient={Boolean(!collectibleEmojiStatus && profileColorSet.bgColors[1])}
className={styles.radialPatternBackground}
yPosition={PATTERN_Y_SHIFT}
/>
)}
{pinnedGifts && (
<ProfilePinnedGifts
peerId={peerId}
gifts={pinnedGifts}
isExpanded={isExpanded}
className={styles.pinnedGifts}
withGlow={!isPlain}
/>
)}
{isExpanded && (
<div className={styles.photoWrapper} style={createVtnStyle('photoWrapper', true)}>
{renderPhotoTabs()}
{!isForSettings && profilePhotos?.personalPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
isFirst && styles.fallbackPhotoVisible,
)}
>
<div className={styles.fallbackPhotoContents}>
{oldLang(profilePhotos.personalPhoto.isVideo ? 'UserInfo.CustomVideo' : 'UserInfo.CustomPhoto')}
</div>
</div>
)}
{isForSettings && profilePhotos?.fallbackPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
(isFirst || isLast) && styles.fallbackPhotoVisible,
)}
>
<div className={styles.fallbackPhotoContents} onClick={handleSelectFallbackPhoto}>
{!isLast && (
<Avatar
photo={profilePhotos.fallbackPhoto}
className={styles.fallbackPhotoAvatar}
size="mini"
/>
)}
{oldLang(profilePhotos.fallbackPhoto.isVideo ? 'UserInfo.PublicVideo' : 'UserInfo.PublicPhoto')}
</div>
</div>
)}
<Transition
activeKey={currentPhotoIndex}
name={resolveTransitionName('slide', animationLevel, !hasSlideAnimation, lang.isRtl)}
>
{renderPhoto}
</Transition>
{!isFirst && (
<button
type="button"
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.navigation, styles.navigation_prev)}
aria-label={oldLang('AccDescrPrevious')}
onClick={selectPreviousMedia}
/>
)}
{!isLast && (
<button
type="button"
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.navigation, styles.navigation_next)}
aria-label={oldLang('Next')}
onClick={selectNextMedia}
/>
)}
</div>
)}
{!isExpanded && (
<Avatar
withStory
storyColors={profileColorSet?.storyColors}
className={styles.standaloneAvatar}
key={peer?.id}
size="jumbo"
peer={peer}
style={createVtnStyle('avatar', true)}
onClick={handleMinimizedAvatarClick}
/>
)}
<div
className={styles.info}
dir={lang.isRtl ? 'rtl' : 'auto'}
style={createVtnStyle('info', true)}
>
{(user || chat) && (
<FullNameTitle
className={styles.title}
style={createVtnStyle('title', true)}
peer={(user || chat)!}
withEmojiStatus
withStatusTextColor
emojiStatusSize={EMOJI_STATUS_SIZE}
onEmojiStatusClick={handleStatusClick}
noLoopLimit
canCopyTitle
/>
)}
{renderStatus()}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { peerId }): Complete<StateProps> => {
const peer = selectPeer(global, peerId);
const user = selectUser(global, peerId);
const userFullInfo = user ? selectUserFullInfo(global, peerId) : undefined;
const userStatus = selectUserStatus(global, peerId);
const chat = selectChat(global, peerId);
const profilePhotos = selectPeerPhotos(global, peerId);
const { mediaIndex, chatId: avatarOwnerId } = selectTabState(global).mediaViewer;
const isForum = chat?.isForum;
const { threadId: currentTopicId } = selectCurrentMessageList(global) || {};
const topic = isForum && currentTopicId ? selectTopic(global, peerId, currentTopicId) : undefined;
const { animationLevel } = selectSharedSettings(global);
const emojiStatus = peer?.emojiStatus;
const emojiStatusSticker = emojiStatus ? selectCustomEmoji(global, emojiStatus.documentId) : undefined;
const emojiStatusSlug = emojiStatus?.type === 'collectible' ? emojiStatus.slug : undefined;
const profileColor = peer && selectPeerProfileColor(global, peer);
const theme = selectTheme(global);
const hasBackground = selectPeerHasProfileBackground(global, peerId);
const savedGifts = selectPeerSavedGifts(global, peerId);
return {
user,
userFullInfo,
userStatus,
chat,
mediaIndex,
avatarOwnerId,
animationLevel,
emojiStatusSticker,
emojiStatusSlug,
emojiStatus,
profilePhotos,
topic,
messagesCount: topic ? selectThreadMessagesCount(global, peerId, currentTopicId!) : undefined,
profileColorOption: profileColor,
theme,
isPlain: !hasBackground,
savedGifts,
};
},
)(ProfileInfo));