Profile: Redesign (#6895)

This commit is contained in:
Alexander Zinchuk 2026-05-05 13:46:42 +02:00
parent e881ecdb3e
commit 6ceb7b6573
35 changed files with 1253 additions and 759 deletions

View File

@ -10,11 +10,11 @@
.video-duration { .video-duration {
position: absolute; position: absolute;
top: 0.25rem; top: 0.3125rem;
left: 0.25rem; left: 0.3125rem;
padding: 0 0.3125rem; padding: 0 0.3125rem;
border-radius: 0.1875rem; border-radius: 0.375rem;
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.125rem; line-height: 1.125rem;

View File

@ -13,7 +13,7 @@
padding-top: 0.875rem; padding-top: 0.875rem;
border-radius: 0.625rem; border-radius: 0.625rem;
background-color: var(--color-background-secondary); background-color: var(--color-background);
&::before { &::before {
pointer-events: none; pointer-events: none;
@ -30,7 +30,7 @@
} }
&:hover::before { &:hover::before {
opacity: 1; opacity: 0.75;
} }
} }

View File

@ -19,7 +19,7 @@
grid-template-rows: auto auto; grid-template-rows: auto auto;
column-gap: 0.5rem; column-gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 1rem;
@include mixins.with-vt-type('chatExtra'); @include mixins.with-vt-type('chatExtra');
} }
@ -40,6 +40,10 @@
@include mixins.with-vt-type('chatExtra'); @include mixins.with-vt-type('chatExtra');
} }
.switch {
margin-inline-start: auto;
}
.botVerificationSection, .botVerificationSection,
.sectionInfo { .sectionInfo {
font-size: 0.875rem; font-size: 0.875rem;
@ -71,6 +75,7 @@
.personalChannelItem { .personalChannelItem {
grid-column: 1 / span 2; grid-column: 1 / span 2;
grid-row: 2; grid-row: 2;
margin-top: 0.375rem;
:global(.Avatar) { :global(.Avatar) {
margin-right: 0.9375rem !important; margin-right: 0.9375rem !important;
@ -144,6 +149,10 @@
cursor: var(--custom-cursor, pointer); cursor: var(--custom-cursor, pointer);
} }
.securityRiskIsland {
margin-bottom: 1rem;
}
.unofficialSecurityRisk { .unofficialSecurityRisk {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
padding-inline: 0.5rem; padding-inline: 0.5rem;

View File

@ -57,10 +57,11 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import Island from '../../gili/layout/Island';
import Switch from '../../gili/primitives/Switch';
import Chat from '../../left/main/Chat'; import Chat from '../../left/main/Chat';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import ListItem from '../../ui/ListItem'; import ListItem from '../../ui/ListItem';
import Switcher from '../../ui/Switcher';
import CompactMapPreview from '../CompactMapPreview'; import CompactMapPreview from '../CompactMapPreview';
import CustomEmoji from '../CustomEmoji'; import CustomEmoji from '../CustomEmoji';
import Icon from '../icons/Icon'; import Icon from '../icons/Icon';
@ -75,7 +76,9 @@ type OwnProps = {
isOwnProfile?: boolean; isOwnProfile?: boolean;
isSavedDialog?: boolean; isSavedDialog?: boolean;
isInSettings?: boolean; isInSettings?: boolean;
withIslands?: boolean;
className?: string; className?: string;
style?: string;
}; };
type StateProps = { type StateProps = {
@ -127,7 +130,9 @@ const ChatExtra = ({
botAppPermissions, botAppPermissions,
botVerification, botVerification,
className, className,
style,
isInSettings, isInSettings,
withIslands,
canViewSubscribers, canViewSubscribers,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
@ -387,13 +392,17 @@ const ChatExtra = ({
); );
} }
const Wrapper = withIslands ? Island : 'div';
return ( return (
<div className={buildClassName('ChatExtra', className)} style={createVtnStyle('chatExtra')}> <div className={buildClassName('ChatExtra', className)} style={style || createVtnStyle('chatExtra')}>
{user && userFullInfo?.isUnofficialSecurityRisk && ( {user && userFullInfo?.isUnofficialSecurityRisk && (
<div className={styles.unofficialSecurityRisk}> <Wrapper className={withIslands ? styles.securityRiskIsland : undefined}>
<Icon className={buildClassName(styles.riskIcon, 'in-text-icon')} name="info-filled" /> <div className={styles.unofficialSecurityRisk}>
{lang('UnofficialSecurityRisk', { peer: getPeerTitle(lang, user) })} <Icon className={buildClassName(styles.riskIcon, 'in-text-icon')} name="info-filled" />
</div> {lang('UnofficialSecurityRisk', { peer: getPeerTitle(lang, user) })}
</div>
</Wrapper>
)} )}
{personalChannel && ( {personalChannel && (
<div className={styles.personalChannel} style={createVtnStyle('personalChannel')}> <div className={styles.personalChannel} style={createVtnStyle('personalChannel')}>
@ -401,244 +410,244 @@ const ChatExtra = ({
<span className={styles.personalChannelSubscribers}> <span className={styles.personalChannelSubscribers}>
{oldLang('Subscribers', personalChannel.membersCount, 'i')} {oldLang('Subscribers', personalChannel.membersCount, 'i')}
</span> </span>
<Chat <Wrapper className={styles.personalChannelItem}>
chatId={personalChannel.id} <Chat
orderDiff={0} chatId={personalChannel.id}
shiftDiff={0} orderDiff={0}
animationType={ChatAnimationTypes.None} shiftDiff={0}
isPreview animationType={ChatAnimationTypes.None}
previewMessageId={personalChannelMessageId} isPreview
className={styles.personalChannelItem} previewMessageId={personalChannelMessageId}
/> />
</Wrapper>
</div> </div>
)} )}
{Boolean(formattedNumber?.length) && ( <Wrapper>
<ListItem {Boolean(formattedNumber?.length) && (
icon="phone" <ListItem
className={styles.phone} icon="phone"
multiline className={styles.phone}
narrow multiline
ripple narrow
onClick={handlePhoneClick} ripple
style={createVtnStyle('phone')} onClick={handlePhoneClick}
> style={createVtnStyle('phone')}
<span className="title" dir={lang.isRtl ? 'rtl' : undefined}>{formattedNumber}</span>
<span className="subtitle">{oldLang('Phone')}</span>
</ListItem>
)}
{activeUsernames && renderUsernames(activeUsernames)}
{description && Boolean(description.length) && (
<ListItem
icon="info"
className={styles.description}
multiline
narrow
isStatic
allowSelection
style={createVtnStyle('description')}
>
<span className="title word-break allow-selection" dir={lang.isRtl ? 'rtl' : undefined}>
{
renderText(description, [
'br',
shouldRenderAllLinks ? 'links' : 'tg_links',
'emoji',
])
}
</span>
<span className="subtitle">{oldLang(userId ? 'UserBio' : 'Info')}</span>
</ListItem>
)}
{activeChatUsernames && !isTopicInfo && renderUsernames(activeChatUsernames, true)}
{((!activeChatUsernames && canInviteUsers) || isTopicInfo) && link && (
<ListItem
icon="link"
multiline
className={styles.link}
narrow
ripple
onClick={() => copy(link, oldLang('SetUrlPlaceholder'))}
style={createVtnStyle('link')}
>
<div className="title">{link}</div>
<span className="subtitle">{oldLang('SetUrlPlaceholder')}</span>
</ListItem>
)}
{birthday && (
<UserBirthday key={peerId} birthday={birthday} user={user!} isInSettings={isInSettings} />
)}
{hasMainMiniApp && (
<ListItem
multiline
className={styles.miniapp}
isStatic
narrow
style={createVtnStyle('miniapp')}
>
<Button
className={styles.openAppButton}
onClick={handleOpenApp}
> >
{oldLang('ProfileBotOpenApp')} <span className="title" dir={lang.isRtl ? 'rtl' : undefined}>{formattedNumber}</span>
</Button> <span className="subtitle">{oldLang('Phone')}</span>
<div className={styles.sectionInfo}> </ListItem>
{appTermsInfo} )}
</div> {activeUsernames && renderUsernames(activeUsernames)}
</ListItem> {description && Boolean(description.length) && (
)} <ListItem
{!isOwnProfile && !isInSettings && ( icon="info"
<ListItem className={styles.description}
icon={isMuted ? 'mute' : 'unmute'} multiline
className={styles.notifications} narrow
narrow isStatic
ripple allowSelection
onClick={handleToggleNotifications} style={createVtnStyle('description')}
style={createVtnStyle('notifications')}
>
<span>{lang('Notifications')}</span>
<Switcher
id="group-notifications"
label={lang(userId ? 'AriaToggleUserNotifications' : 'AriaToggleChatNotifications')}
checked={!isMuted}
inactive
/>
</ListItem>
)}
{businessWorkHours && (
<BusinessHours businessHours={businessWorkHours} />
)}
{businessLocation && (
<ListItem
icon="location"
ripple
multiline
narrow
className={styles.location}
style={createVtnStyle('location')}
rightElement={locationRightComponent}
onClick={handleClickLocation}
>
<div className="title">{businessLocation.address}</div>
<span className="subtitle">{oldLang('BusinessProfileLocation')}</span>
</ListItem>
)}
{shouldRenderNote && (
<ListItem
icon="note"
iconClassName={styles.noteListItemIcon}
multiline
narrow
isStatic
allowSelection
className={styles.note}
style={createVtnStyle('note')}
>
<div
ref={noteTextRef}
className={buildClassName(
'title',
'word-break',
'allow-selection',
styles.noteText,
isNoteCollapsed && styles.noteTextCollapsed,
)}
style={createVtnStyle('noteText', true)}
dir={lang.isRtl ? 'rtl' : undefined}
onClick={canExpandNote ? handleExpandNote : undefined}
> >
{renderTextWithEntities({ <span className="title word-break allow-selection" dir={lang.isRtl ? 'rtl' : undefined}>
text: note.text, {
entities: note.entities, renderText(description, [
})} 'br',
</div> shouldRenderAllLinks ? 'links' : 'tg_links',
<div className={buildClassName('subtitle', styles.noteSubtitle)} style={createVtnStyle('noteSubtitle')}> 'emoji',
<span>{lang('UserNoteTitle')}</span> ])
}
</span>
<span className="subtitle">{oldLang(userId ? 'UserBio' : 'Info')}</span>
</ListItem>
)}
{activeChatUsernames && !isTopicInfo && renderUsernames(activeChatUsernames, true)}
{((!activeChatUsernames && canInviteUsers) || isTopicInfo) && link && (
<ListItem
icon="link"
multiline
className={styles.link}
narrow
ripple
onClick={() => copy(link, oldLang('SetUrlPlaceholder'))}
style={createVtnStyle('link')}
>
<div className="title">{link}</div>
<span className="subtitle">{oldLang('SetUrlPlaceholder')}</span>
</ListItem>
)}
{birthday && (
<UserBirthday key={peerId} birthday={birthday} user={user!} isInSettings={isInSettings} />
)}
{hasMainMiniApp && (
<ListItem
multiline
className={styles.miniapp}
isStatic
narrow
style={createVtnStyle('miniapp')}
>
<Button
className={styles.openAppButton}
onClick={handleOpenApp}
>
{oldLang('ProfileBotOpenApp')}
</Button>
<div className={styles.sectionInfo}>
{appTermsInfo}
</div>
</ListItem>
)}
{!isOwnProfile && !isInSettings && (
<ListItem
icon={isMuted ? 'mute' : 'unmute'}
className={styles.notifications}
narrow
ripple
onClick={handleToggleNotifications}
style={createVtnStyle('notifications')}
>
<span>{lang('Notifications')}</span>
<Switch
id="group-notifications"
checked={!isMuted}
className={styles.switch}
/>
</ListItem>
)}
{businessWorkHours && (
<BusinessHours businessHours={businessWorkHours} />
)}
{businessLocation && (
<ListItem
icon="location"
ripple
multiline
narrow
className={styles.location}
style={createVtnStyle('location')}
rightElement={locationRightComponent}
onClick={handleClickLocation}
>
<div className="title">{businessLocation.address}</div>
<span className="subtitle">{oldLang('BusinessProfileLocation')}</span>
</ListItem>
)}
{shouldRenderNote && (
<ListItem
icon="note"
iconClassName={styles.noteListItemIcon}
multiline
narrow
isStatic
allowSelection
className={styles.note}
style={createVtnStyle('note')}
>
<div
ref={noteTextRef}
className={buildClassName(
'title',
'word-break',
'allow-selection',
styles.noteText,
isNoteCollapsed && styles.noteTextCollapsed,
)}
style={createVtnStyle('noteText', true)}
dir={lang.isRtl ? 'rtl' : undefined}
onClick={canExpandNote ? handleExpandNote : undefined}
>
{renderTextWithEntities({
text: note.text,
entities: note.entities,
})}
</div>
<div className={buildClassName('subtitle', styles.noteSubtitle)} style={createVtnStyle('noteSubtitle')}>
<span>{lang('UserNoteTitle')}</span>
<span className={styles.noteHint}>{lang('UserNoteHint')}</span> <span className={styles.noteHint}>{lang('UserNoteHint')}</span>
{isNoteCollapsible && ( {isNoteCollapsible && (
<Icon <Icon
className={buildClassName( className={buildClassName(
styles.noteExpandIcon, styles.noteExpandIcon,
styles.clickable, styles.clickable,
)} )}
style={createVtnStyle('noteExpandIcon', true)} style={createVtnStyle('noteExpandIcon', true)}
onClick={handleToggleNote} onClick={handleToggleNote}
name={isNoteCollapsed ? 'down' : 'up'} name={isNoteCollapsed ? 'down' : 'up'}
/> />
)} )}
</div>
</ListItem>
)}
{hasSavedMessages && !isOwnProfile && !isInSettings && (
<ListItem
icon="saved-messages"
className={styles.savedMessages}
narrow
ripple
onClick={handleOpenSavedDialog}
style={createVtnStyle('savedMessages')}
>
<span>{oldLang('SavedMessagesTab')}</span>
</ListItem>
)}
{userFullInfo && 'isBotAccessEmojiGranted' in userFullInfo && (
<ListItem
icon="user"
className={styles.botEmojiStatus}
narrow
ripple
onClick={manageEmojiStatusChange}
style={createVtnStyle('botEmojiStatus')}
>
<span>{oldLang('BotProfilePermissionEmojiStatus')}</span>
<Switch
checked={Boolean(isBotCanManageEmojiStatus)}
className={styles.switch}
/>
</ListItem>
)}
{botAppPermissions?.geolocation !== undefined && (
<ListItem
icon="location"
className={styles.botLocation}
narrow
ripple
onClick={handleLocationPermissionChange}
style={createVtnStyle('botLocation')}
>
<span>{oldLang('BotProfilePermissionLocation')}</span>
<Switch
checked={Boolean(botAppPermissions?.geolocation)}
className={styles.switch}
/>
</ListItem>
)}
{canViewSubscribers && (
<ListItem
icon="group"
narrow
multiline
ripple
className={styles.subscribers}
onClick={handleOpenSubscribers}
style={createVtnStyle('subscribers')}
>
<div className="title">{lang('ProfileItemSubscribers')}</div>
<span className="subtitle">{lang.number(chat?.membersCount || 0)}</span>
</ListItem>
)}
{botVerification && (
<div className={styles.botVerificationSection} style={createVtnStyle('botVerification')}>
<CustomEmoji
className={styles.botVerificationIcon}
documentId={botVerification.iconId}
size={BOT_VERIFICATION_ICON_SIZE}
/>
{botVerification.description}
</div> </div>
</ListItem> )}
)} </Wrapper>
{hasSavedMessages && !isOwnProfile && !isInSettings && (
<ListItem
icon="saved-messages"
className={styles.savedMessages}
narrow
ripple
onClick={handleOpenSavedDialog}
style={createVtnStyle('savedMessages')}
>
<span>{oldLang('SavedMessagesTab')}</span>
</ListItem>
)}
{userFullInfo && 'isBotAccessEmojiGranted' in userFullInfo && (
<ListItem
icon="user"
className={styles.botEmojiStatus}
narrow
ripple
onClick={manageEmojiStatusChange}
style={createVtnStyle('botEmojiStatus')}
>
<span>{oldLang('BotProfilePermissionEmojiStatus')}</span>
<Switcher
label={oldLang('BotProfilePermissionEmojiStatus')}
checked={isBotCanManageEmojiStatus}
inactive
/>
</ListItem>
)}
{botAppPermissions?.geolocation !== undefined && (
<ListItem
icon="location"
className={styles.botLocation}
narrow
ripple
onClick={handleLocationPermissionChange}
style={createVtnStyle('botLocation')}
>
<span>{oldLang('BotProfilePermissionLocation')}</span>
<Switcher
label={oldLang('BotProfilePermissionLocation')}
checked={botAppPermissions?.geolocation}
inactive
/>
</ListItem>
)}
{canViewSubscribers && (
<ListItem
icon="group"
narrow
multiline
ripple
className={styles.subscribers}
onClick={handleOpenSubscribers}
style={createVtnStyle('subscribers')}
>
<div className="title">{lang('ProfileItemSubscribers')}</div>
<span className="subtitle">{lang.number(chat?.membersCount || 0)}</span>
</ListItem>
)}
{botVerification && (
<div className={styles.botVerificationSection} style={createVtnStyle('botVerification')}>
<CustomEmoji
className={styles.botVerificationIcon}
documentId={botVerification.iconId}
size={BOT_VERIFICATION_ICON_SIZE}
/>
{botVerification.description}
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,6 +1,7 @@
@use "../../../styles/mixins"; @use "../../../styles/mixins";
.root { .root {
--profile-info-bg: var(--color-background);
--rating-outline-color: #000000; --rating-outline-color: #000000;
--rating-text-color: #000000; --rating-text-color: #000000;
@ -14,7 +15,7 @@
color: var(--color-white); color: var(--color-white);
background-color: var(--color-background); background-color: var(--profile-info-bg);
@include mixins.with-vt-type('profileAvatar'); @include mixins.with-vt-type('profileAvatar');
@ -33,7 +34,7 @@
color: var(--color-text); color: var(--color-text);
.info { .info {
background-image: linear-gradient(0deg, var(--color-background) 50%, transparent); background-image: linear-gradient(0deg, var(--profile-info-bg) 50%, transparent);
} }
.userRatingNegativeWrapper, .userRatingNegativeWrapper,
@ -445,11 +446,6 @@
} }
@include mixins.on-active-vt('profileAvatar') { @include mixins.on-active-vt('profileAvatar') {
&::view-transition-old(.profileInfo),
&::view-transition-new(.profileInfo) {
animation-name: none;
}
&::view-transition-group(.photoDashes) { &::view-transition-group(.photoDashes) {
z-index: 1; z-index: 1;
} }
@ -460,10 +456,22 @@
&::view-transition-group(.profileInfo) { &::view-transition-group(.profileInfo) {
z-index: -2; z-index: -2;
overflow: clip;
}
&::view-transition-old(.profileInfo),
&::view-transition-new(.profileInfo) {
object-fit: none;
object-position: top;
animation-name: none;
} }
} }
@include mixins.on-active-vt('profileExpand') { @include mixins.on-active-vt('profileExpand') {
&::view-transition-old(.profileInfo) {
opacity: 0;
}
&::view-transition-old(.avatar) { &::view-transition-old(.avatar) {
z-index: -1; z-index: -1;
animation-name: none; animation-name: none;
@ -487,6 +495,10 @@
} }
@include mixins.on-active-vt('profileCollapse') { @include mixins.on-active-vt('profileCollapse') {
&::view-transition-new(.profileInfo) {
opacity: 0;
}
&::view-transition-new(.avatar) { &::view-transition-new(.avatar) {
z-index: -1; z-index: -1;
animation-name: none; animation-name: none;

View File

@ -12,4 +12,8 @@
@include mixins.adapt-padding-to-scrollbar(1rem); @include mixins.adapt-padding-to-scrollbar(1rem);
} }
.noPadding {
padding-inline: 0;
}
} }

View File

@ -1,14 +1,20 @@
import type { ElementRef } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import styles from './Surface.module.scss'; import styles from './Surface.module.scss';
type OwnProps = React.HTMLAttributes<HTMLDivElement> & { type OwnProps = React.HTMLAttributes<HTMLDivElement> & {
ref?: ElementRef<HTMLDivElement>;
scrollable?: boolean; scrollable?: boolean;
noPadding?: boolean;
children: React.ReactNode; children: React.ReactNode;
}; };
const Surface = ({ const Surface = ({
ref,
scrollable, scrollable,
noPadding,
className, className,
children, children,
...otherProps ...otherProps
@ -17,10 +23,12 @@ const Surface = ({
return ( return (
<div <div
ref={ref}
className={buildClassName( className={buildClassName(
styles.root, styles.root,
isScrollable && 'custom-scroll', isScrollable && 'custom-scroll',
isScrollable && styles.scrollable, isScrollable && styles.scrollable,
noPadding && styles.noPadding,
className, className,
)} )}
{...otherProps} {...otherProps}

View File

@ -19,7 +19,7 @@ type OwnProps = {
disabled?: boolean; disabled?: boolean;
withPermissionColors?: boolean; withPermissionColors?: boolean;
className?: string; className?: string;
onChange: (checked: boolean) => void; onChange?: (checked: boolean) => void;
}; };
type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>; type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
@ -40,7 +40,7 @@ const Switch = ({
const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading; const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading;
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.checked); onChange?.(e.currentTarget.checked);
}); });
if (interactive?.isLoading) return undefined; if (interactive?.isLoading) return undefined;

View File

@ -413,7 +413,7 @@ const Chat: FC<OwnProps & StateProps> = ({
ref={ref} ref={ref}
className={chatClassName} className={chatClassName}
href={href} href={href}
style={`top: ${offsetTop}px`} style={offsetTop !== undefined ? `top: ${offsetTop}px` : undefined}
ripple={!isForum && !isMobile} ripple={!isForum && !isMobile}
contextActions={contextActions} contextActions={contextActions}
withPortalForMenu withPortalForMenu

View File

@ -0,0 +1,44 @@
@layer component.chatFolderTabList {
.root {
--fade-mask-height: 2.625rem;
position: absolute;
z-index: 4;
top: 0.25rem;
inset-inline: 0.5rem;
transition: opacity var(--layer-transition);
}
.hidden {
pointer-events: none;
opacity: 0.25;
}
.badge {
flex-shrink: 0;
min-width: 1.25rem;
height: 1.25rem;
margin-inline-start: 0.25rem;
padding: 0 0.3125rem;
border-radius: 0.75rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
line-height: 1.25rem;
color: white;
text-align: center;
background: var(--color-gray);
}
.badgeActive {
background: var(--color-primary);
}
:global(html.theme-dark) .tabList {
border: 1px solid var(--color-borders);
box-shadow: none;
}
}

View File

@ -0,0 +1,51 @@
import { memo } from '../../../lib/teact/teact';
import type { TabWithProperties } from '../../ui/TabList';
import buildClassName from '../../../util/buildClassName';
import useLastCallback from '../../../hooks/useLastCallback';
import TabList from '../../ui/TabList';
import styles from './ChatFolderTabList.module.scss';
type OwnProps = {
tabs: readonly TabWithProperties[];
activeTab: number;
isHidden?: boolean;
className?: string;
onSwitchTab: (index: number) => void;
};
const ChatFolderTabList = ({
tabs,
activeTab,
isHidden,
className,
onSwitchTab,
}: OwnProps) => {
const renderExtra = useLastCallback((tab: TabWithProperties) => {
if (!tab.badgeCount) return undefined;
return (
<span className={buildClassName(styles.badge, tab.isBadgeActive && styles.badgeActive)}>
{tab.badgeCount}
</span>
);
});
return (
<div className={buildClassName(styles.root, isHidden && styles.hidden)}>
<TabList
tabs={tabs}
activeTab={activeTab}
withFadeMask
renderExtra={renderExtra}
className={buildClassName(styles.tabList, className)}
onSwitchTab={onSwitchTab}
/>
</div>
);
};
export default memo(ChatFolderTabList);

View File

@ -26,8 +26,8 @@ import useScrolledState from '../../../hooks/useScrolledState';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import StoryRibbon from '../../story/StoryRibbon'; import StoryRibbon from '../../story/StoryRibbon';
import SquareTabList from '../../ui/SquareTabList';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import ChatFolderTabList from './ChatFolderTabList';
import ChatList from './ChatList'; import ChatList from './ChatList';
type OwnProps = { type OwnProps = {
@ -87,7 +87,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
const lang = useLang(); const lang = useLang();
const { isAtBeginning: isNotScrolled, handleScroll, updateScrollState } = useScrolledState(); const { handleScroll, updateScrollState } = useScrolledState();
useEffect(() => { useEffect(() => {
loadChatFolders(); loadChatFolders();
@ -119,6 +119,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
const { displayedFolders, folderTabs } = useFolderTabs({ const { displayedFolders, folderTabs } = useFolderTabs({
sidebarMode: false, sidebarMode: false,
noEmoticons: true,
orderedFolderIds, orderedFolderIds,
chatFoldersById, chatFoldersById,
maxFolders, maxFolders,
@ -252,31 +253,31 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
ref={ref} ref={ref}
className={buildClassName( className={buildClassName(
'ChatFolders', 'ChatFolders',
shouldRenderFolders && shouldHideFolderTabs && 'ChatFolders--tabs-hidden',
shouldRenderStoryRibbon && 'with-story-ribbon', shouldRenderStoryRibbon && 'with-story-ribbon',
isFoldersSidebarShown && 'ChatFolders--tabs-sidebar-shown', isFoldersSidebarShown && 'ChatFolders--tabs-sidebar-shown',
)} )}
> >
{shouldRenderStoryRibbon && <StoryRibbon isClosing={isStoryRibbonClosing} />} {shouldRenderStoryRibbon && <StoryRibbon isClosing={isStoryRibbonClosing} />}
{shouldRenderFolders ? ( <div className={buildClassName('ChatFolders-content', shouldRenderFolders && 'with-tabs')}>
<SquareTabList {shouldRenderFolders ? (
contextRootElementSelector="#LeftColumn" <ChatFolderTabList
tabs={folderTabs} tabs={folderTabs}
activeTab={activeChatFolder} activeTab={activeChatFolder}
onSwitchTab={handleSwitchTab} isHidden={shouldHideFolderTabs}
className={!isNotScrolled ? 'scrolled' : undefined} onSwitchTab={handleSwitchTab}
/> />
) : shouldRenderPlaceholder ? ( ) : shouldRenderPlaceholder ? (
<div ref={placeholderRef} className="tabs-placeholder" /> <div ref={placeholderRef} className="tabs-placeholder" />
) : undefined} ) : undefined}
<Transition <Transition
ref={transitionRef} ref={transitionRef}
name={resolveTransitionName('slideOptimized', animationLevel, shouldSkipHistoryAnimations, lang.isRtl)} name={resolveTransitionName('slideOptimized', animationLevel, shouldSkipHistoryAnimations, lang.isRtl)}
activeKey={activeChatFolder} activeKey={activeChatFolder}
renderCount={hasFolders ? folderTabs.length : undefined} renderCount={hasFolders ? folderTabs.length : undefined}
> >
{renderCurrentTab} {renderCurrentTab}
</Transition> </Transition>
</div>
</div> </div>
); );
}; };

View File

@ -48,6 +48,11 @@ type OwnProps = {
isFoldersSidebarShown?: boolean; isFoldersSidebarShown?: boolean;
isStoryRibbonShown?: boolean; isStoryRibbonShown?: boolean;
foldersDispatch?: FolderEditDispatch; foldersDispatch?: FolderEditDispatch;
noAbsolutePositioning?: boolean;
noVirtualization?: boolean;
noScrollRestore?: boolean;
noFastList?: boolean;
scrollContainerClosest?: string;
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void; onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
}; };
@ -67,6 +72,11 @@ const ChatList = ({
isFoldersSidebarShown, isFoldersSidebarShown,
isStoryRibbonShown, isStoryRibbonShown,
foldersDispatch, foldersDispatch,
noAbsolutePositioning,
noVirtualization,
noScrollRestore,
noFastList,
scrollContainerClosest,
onScroll, onScroll,
}: OwnProps) => { }: OwnProps) => {
const { const {
@ -99,7 +109,10 @@ const ChatList = ({
orderDiffById, shiftDiff, getAnimationType, onReorderAnimationEnd: onReorderAnimationEnd, orderDiffById, shiftDiff, getAnimationType, onReorderAnimationEnd: onReorderAnimationEnd,
} = useOrderDiff(orderedIds, panesHeight); } = useOrderDiff(orderedIds, panesHeight);
const [viewportIds, getMore] = useInfiniteScroll(undefined, orderedIds, undefined, CHAT_LIST_SLICE); const chatListSlice = noVirtualization
? Math.max(CHAT_LIST_SLICE, orderedIds?.length || 0)
: CHAT_LIST_SLICE;
const [viewportIds, getMore] = useInfiniteScroll(undefined, orderedIds, undefined, chatListSlice);
// Support <Alt>+<Up/Down> to navigate between chats // Support <Alt>+<Up/Down> to navigate between chats
useHotkeys(useMemo(() => (isActive && orderedIds?.length ? { useHotkeys(useMemo(() => (isActive && orderedIds?.length ? {
@ -194,7 +207,9 @@ const ChatList = ({
return viewportIds!.map((id, i) => { return viewportIds!.map((id, i) => {
const isPinned = viewportOffset + i < pinnedCount; const isPinned = viewportOffset + i < pinnedCount;
const offsetTop = panesHeight + archiveHeight + (viewportOffset + i) * CHAT_HEIGHT_PX; const offsetTop = noAbsolutePositioning
? undefined
: panesHeight + archiveHeight + (viewportOffset + i) * CHAT_HEIGHT_PX;
return ( return (
<Chat <Chat
@ -219,6 +234,8 @@ const ChatList = ({
}); });
} }
const totalHeight = chatsHeight + archiveHeight + panesHeight;
return ( return (
<InfiniteScroll <InfiniteScroll
className={buildClassName('chat-list custom-scroll', isForumPanelOpen && 'forum-panel-open', className)} className={buildClassName('chat-list custom-scroll', isForumPanelOpen && 'forum-panel-open', className)}
@ -226,8 +243,11 @@ const ChatList = ({
items={viewportIds} items={viewportIds}
itemSelector=".ListItem:not(.chat-item-archive)" itemSelector=".ListItem:not(.chat-item-archive)"
preloadBackwards={CHAT_LIST_SLICE} preloadBackwards={CHAT_LIST_SLICE}
withAbsolutePositioning withAbsolutePositioning={!noAbsolutePositioning}
maxHeight={chatsHeight + archiveHeight + panesHeight} maxHeight={!noAbsolutePositioning ? totalHeight : undefined}
scrollContainerClosest={scrollContainerClosest}
noScrollRestore={noScrollRestore}
noFastList={noFastList}
onLoadMore={getMore} onLoadMore={getMore}
onScroll={onScroll} onScroll={onScroll}
> >

View File

@ -30,35 +30,27 @@
} }
} }
.SquareTabList {
z-index: 1;
justify-content: flex-start;
border-bottom: 1px solid var(--color-borders);
opacity: 1;
box-shadow: none;
transition: opacity var(--layer-transition), box-shadow 0.2s, border-color 0.2s;
&.scrolled {
border-bottom-color: transparent;
box-shadow: 0 2px 2px var(--color-light-shadow);
}
}
&--tabs-hidden .SquareTabList {
pointer-events: none;
opacity: 0.25;
}
&--tabs-sidebar-shown .chat-list { &--tabs-sidebar-shown .chat-list {
padding-top: 0; padding-top: 0;
} }
.Tab { &-content {
flex: 0 0 auto; position: relative;
display: flex;
flex: 1;
flex-direction: column;
> .Transition {
flex: 1;
}
&.with-tabs > .Transition {
margin-top: 2.5rem;
}
&.with-tabs .chat-list {
padding-top: 1.375rem;
}
} }
> .Transition { > .Transition {

View File

@ -27,26 +27,13 @@
padding: 0; padding: 0;
} }
.tabListWrapper { .fadeMask {
--fade-mask-color: var(--color-background-secondary);
position: absolute; position: absolute;
z-index: 2; z-index: 2;
width: 100%; width: 100%;
&::after {
pointer-events: none;
content: "";
position: absolute;
z-index: 0;
top: calc(100% - 2rem);
left: 0;
width: calc(100% - 2rem);
height: 3.5rem;
margin-inline: 1rem;
background: linear-gradient(to bottom, var(--color-background-secondary) 0%, transparent 100%);
}
} }
.tabList { .tabList {

View File

@ -280,17 +280,17 @@ const AiMessageEditorModal = ({
)} )}
isSlim isSlim
> >
<div className={styles.tabListWrapper}> <TabList
<TabList tabs={tabs}
tabs={tabs} activeTab={activeTabIndex}
activeTab={activeTabIndex} withFadeMask
onSwitchTab={handleTabChange} fadeMaskClassName={styles.fadeMask}
className={styles.tabList} className={styles.tabList}
tabClassName={styles.tab} tabClassName={styles.tab}
stretched stretched
itemAlignment="vertical" itemAlignment="vertical"
/> onSwitchTab={handleTabChange}
</div> />
<div className={styles.transitionWrapper}> <div className={styles.transitionWrapper}>
<Transition <Transition

View File

@ -0,0 +1,326 @@
@use '../../styles/mixins';
.root {
overflow-x: hidden;
display: flex;
flex-direction: column;
height: 100%;
:global(.FloatingActionButton) {
z-index: 1;
@include mixins.with-vt-type('rightColumn');
&:global(.revealed) {
transition-delay: 0.2s;
}
}
}
.hidden {
display: none;
}
.profileInfo {
background-color: var(--color-background-secondary);
:global(.ProfileInfo) {
--profile-info-bg: var(--color-background-secondary);
}
:global(.ChatInfo) {
grid-area: chat_info;
:global(.status.online) {
color: var(--color-primary);
}
}
:global(.ChatExtra) {
:global(.narrow) {
margin-bottom: 0;
}
:global([dir="rtl"]) {
:global(.Switcher) {
margin-right: auto;
margin-left: 0;
}
}
}
}
.chatExtraBlock {
margin: 1rem;
margin-bottom: 0;
@include mixins.adapt-margin-to-scrollbar(1rem);
@include mixins.with-vt-type('rightColumn');
}
.sharedMediaTabs {
--fade-mask-color: var(--color-background-secondary);
position: sticky;
z-index: 2;
top: 1rem;
margin-top: 1rem;
padding-inline: 1rem;
@include mixins.adapt-padding-to-scrollbar(1rem);
@include mixins.with-vt-type('rightColumn');
&::before {
pointer-events: none;
content: "";
position: absolute;
z-index: 0;
top: -1rem;
left: 0;
width: 100%;
height: calc(100% + 1rem);
background: linear-gradient(to bottom, var(--fade-mask-color, var(--color-background)) 0%, transparent 100%);
backdrop-filter: blur(0.5rem);
mask-image: linear-gradient(to bottom, black 65%, transparent 100%);
:global(html.theme-dark) & {
backdrop-filter: blur(1rem);
mask-image: linear-gradient(to bottom, black 85%, transparent 100%);
}
}
}
.nothingFoundGifts {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 5rem;
.description {
unicode-bidi: plaintext;
margin-block: 1rem;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-align: center;
}
:global(.Link) {
font-weight: var(--font-weight-medium);
color: var(--color-links);
transition: opacity 0.15s ease-in;
&:active,
&:hover {
text-decoration: none;
opacity: 0.85;
}
}
}
.sharedMedia {
position: relative;
display: flex;
flex: 1 0 auto;
flex-direction: column;
margin-top: 1rem;
@include mixins.with-vt-type('rightColumn');
:global(.info .Transition) {
flex-grow: 0;
}
.sharedMediaIsland {
overflow: hidden;
margin: 0 1rem 1rem;
padding: 0;
@include mixins.adapt-margin-to-scrollbar(1rem);
:global(.ListItem-button) {
border-radius: 1rem;
}
}
.sharedMediaIslandContainer {
display: flex;
flex-direction: column;
}
.savedDialogsIsland {
overflow: clip;
height: auto;
}
.savedDialogs {
overflow: visible !important;
height: auto !important;
:global(.Chat) {
position: static;
}
}
}
.content {
transition: transform var(--layer-transition);
&.showContentPanel {
transform: translateY(3rem);
}
&.noTransition {
transition: none;
}
&.emptyList {
display: flex;
align-items: flex-start;
justify-content: center;
height: 100%;
padding-top: 5rem;
:global(.Spinner) {
--spinner-size: 2.75rem;
}
}
&.storiesArchiveList,
&.storiesList,
&.mediaList,
&.gifList,
&.previewMediaList,
&.giftsList {
display: grid;
grid-auto-rows: 1fr;
grid-template-columns: repeat(3, 1fr);
gap: 0.0625rem;
}
&.storiesArchiveList,
&.storiesList,
&.mediaList,
&.gifList,
&.previewMediaList {
overflow: hidden;
margin-inline-start: var(--scrollbar-width);
border-radius: var(--border-radius-island);
}
&.giftsList {
gap: 0.625rem;
margin-inline-start: var(--scrollbar-width);
@include mixins.adapt-padding-to-scrollbar(0.5rem);
}
&.documentsList {
padding: 1.25rem;
:global(.File + .File) {
margin-top: 1.25rem;
}
}
&.linksList {
padding: 1.25rem;
:global(.ProgressSpinner),
:global(.message-transfer-progress) {
display: none;
}
}
&.audioList,
&.voiceList {
padding: 1.25rem;
:global(.Audio) {
:global(.media-loading) {
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
& + :global(.Audio) {
margin-top: 1.25rem;
}
}
}
&.similarChannelsList,
&.similarBotsList,
&.commonChatsList,
&.membersList,
&.giftsList {
padding: 0.5rem;
@media (max-width: 600px) {
padding: 0;
}
}
&.similarBotsList,
&.similarChannelsList {
:global(.ListItem.blured) {
filter: opacity(0.8);
}
.showMoreBots,
.showMoreChannels {
z-index: 1;
width: calc(100% - 1rem);
margin: 0 auto;
margin-top: -1.8125rem;
border-radius: var(--border-radius-default-small);
box-shadow: -1rem 0 1rem 1rem var(--color-background), -1rem 0 1rem 0.3125rem var(--color-background);
}
.moreSimilar {
margin-top: 1rem;
font-size: 0.8125rem;
text-align: center;
}
}
}
.contentCategoriesPanel {
position: absolute;
top: 0;
right: 0;
left: 0;
transition: transform var(--layer-transition), opacity 0.2s ease;
&.hiddenPanel {
transform: translateY(-100%);
opacity: 0;
}
&.noTransition {
transition: none;
}
}
.savedGift {
@include mixins.with-vt-type('profileGifts');
}

View File

@ -1,250 +0,0 @@
@use '../../styles/mixins';
.Profile {
scrollbar-gutter: stable;
overflow-x: hidden;
overflow-y: scroll;
display: flex;
flex-direction: column;
height: 100%;
> .profile-info > .ChatInfo {
grid-area: chat_info;
.status.online {
color: var(--color-primary);
}
}
> .profile-info > .ChatExtra {
padding: 0.875rem 0.5rem 0.5rem;
@include mixins.adapt-padding-to-scrollbar(0.5rem);
@include mixins.side-panel-section;
@include mixins.with-vt-type('rightColumn');
.narrow {
margin-bottom: 0;
}
[dir="rtl"] {
.Switcher {
margin-right: auto;
margin-left: 0;
}
}
}
.hidden {
display: none;
}
.FloatingActionButton {
z-index: 1;
&.revealed {
transition-delay: 0.2s;
}
}
}
.nothing-found-gifts {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 5rem;
.description {
unicode-bidi: plaintext;
margin-block: 1rem;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-align: center;
}
.Link {
font-weight: var(--font-weight-medium);
color: var(--color-links);
transition: opacity 0.15s ease-in;
&:active,
&:hover {
text-decoration: none;
opacity: 0.85;
}
}
}
.shared-media {
display: flex;
flex: 1;
flex-direction: column-reverse;
@include mixins.with-vt-type('rightColumn');
.SquareTabList {
z-index: 1;
background: var(--color-background);
}
.info .Transition {
flex-grow: 0;
}
.Transition {
flex: 1;
}
.saved-dialogs {
height: 100% !important;
}
.shared-media-transition {
overflow: hidden;
}
.content {
transition: transform var(--layer-transition);
&.showContentPanel {
transform: translateY(3rem);
padding-bottom: 3.5rem !important;
}
&.noTransition {
transition: none;
}
&.empty-list {
display: flex;
align-items: flex-start;
justify-content: center;
height: 100%;
padding-top: 5rem;
.Spinner {
--spinner-size: 2.75rem;
}
}
&.storiesArchive-list,
&.stories-list,
&.media-list,
&.gif-list,
&.previewMedia-list,
&.gifts-list {
display: grid;
grid-auto-rows: 1fr;
grid-template-columns: repeat(3, 1fr);
gap: 0.0625rem;
}
&.gifts-list {
gap: 0.625rem;
}
&.documents-list {
padding: 1.25rem;
& .File + .File {
margin-top: 1.25rem;
}
}
&.links-list {
padding: 1.25rem;
.ProgressSpinner,
.message-transfer-progress {
display: none;
}
}
&.audio-list,
&.voice-list {
padding: 1.25rem;
& .Audio {
.media-loading {
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
& + .Audio {
margin-top: 1.6875rem;
}
}
}
&.similarChannels-list,
&.similarBots-list,
&.commonChats-list,
&.members-list,
&.gifts-list {
padding: 0.5rem;
@include mixins.adapt-padding-to-scrollbar(0.5rem);
@media (max-width: 600px) {
padding: 0.5rem 0;
}
}
&.similarBots-list,
&.similarChannels-list {
.ListItem.blured {
filter: opacity(0.8);
}
.show-more-bots,
.show-more-channels {
z-index: 1;
width: calc(100% - 1rem);
margin: 0 auto;
margin-top: -1.8125rem;
border-radius: var(--border-radius-default-small);
box-shadow: -1rem 0 1rem 1rem var(--color-background), -1rem 0 1rem 0.3125rem var(--color-background);
}
.more-similar {
margin-top: 1rem;
font-size: 0.8125rem;
text-align: center;
}
}
}
.contentCategoriesPanel {
position: absolute;
top: 0;
right: 0;
left: 0;
transition: transform var(--layer-transition), opacity 0.2s ease;
&.hiddenPanel {
transform: translateY(-100%);
opacity: 0;
}
&.noTransition {
transition: none;
}
}
.saved-gift {
@include mixins.with-vt-type('profileGifts');
}
}

View File

@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from '@teact'; import type { TeactNode } from '@teact';
import { memo, useEffect, useMemo, useRef, useState } from '@teact';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
import type { import type {
@ -18,15 +19,13 @@ import type {
} from '../../api/types'; } from '../../api/types';
import type { ProfileCollectionKey } from '../../global/selectors/payments'; import type { ProfileCollectionKey } from '../../global/selectors/payments';
import type { TabState } from '../../global/types'; import type { TabState } from '../../global/types';
import type { AnimationLevel, ProfileState, ProfileTabType, SharedMediaType, ThemeKey, ThreadId } from '../../types'; import type { AnimationLevel, ProfileState, ProfileTabType,
SharedMediaType, ThemeKey, ThreadId } from '../../types';
import type { RegularLangKey } from '../../types/language'; import type { RegularLangKey } from '../../types/language';
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import { AudioOrigin, MediaViewerOrigin, NewChatMembersProgress } from '../../types'; import { AudioOrigin, LoadMoreDirection, MediaViewerOrigin, NewChatMembersProgress } from '../../types';
import { MEMBERS_SLICE, PROFILE_SENSITIVE_AREA, SHARED_MEDIA_SLICE, SLIDE_TRANSITION_DURATION } from '../../config'; import { MEMBERS_SLICE, PROFILE_SENSITIVE_AREA, SHARED_MEDIA_SLICE, SLIDE_TRANSITION_DURATION } from '../../config';
import { selectActiveGiftsCollectionId } from '../../global/selectors/payments';
const CONTENT_PANEL_SHOW_DELAY = 300;
import { import {
getHasAdminRight, getHasAdminRight,
getIsDownloading, getIsDownloading,
@ -63,6 +62,7 @@ import {
} from '../../global/selectors'; } from '../../global/selectors';
import { selectPremiumLimit } from '../../global/selectors/limits'; import { selectPremiumLimit } from '../../global/selectors/limits';
import { selectMessageDownloadableMedia } from '../../global/selectors/media'; import { selectMessageDownloadableMedia } from '../../global/selectors/media';
import { selectActiveGiftsCollectionId } from '../../global/selectors/payments';
import { selectSharedSettings } from '../../global/selectors/sharedState'; import { selectSharedSettings } from '../../global/selectors/sharedState';
import { selectActiveStoriesCollectionId } from '../../global/selectors/stories'; import { selectActiveStoriesCollectionId } from '../../global/selectors/stories';
import { import {
@ -85,7 +85,6 @@ import { useViewTransition } from '../../hooks/animations/useViewTransition';
import { useVtn } from '../../hooks/animations/useVtn.ts'; import { useVtn } from '../../hooks/animations/useVtn.ts';
import usePeerStoriesPolling from '../../hooks/polling/usePeerStoriesPolling'; import usePeerStoriesPolling from '../../hooks/polling/usePeerStoriesPolling';
import useTopOverscroll from '../../hooks/scroll/useTopOverscroll.tsx'; import useTopOverscroll from '../../hooks/scroll/useTopOverscroll.tsx';
import useCacheBuster from '../../hooks/useCacheBuster';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
@ -111,6 +110,8 @@ import PrivateChatInfo from '../common/PrivateChatInfo';
import ChatExtra from '../common/profile/ChatExtra'; import ChatExtra from '../common/profile/ChatExtra';
import ProfileInfo from '../common/profile/ProfileInfo.tsx'; import ProfileInfo from '../common/profile/ProfileInfo.tsx';
import WebLink from '../common/WebLink'; import WebLink from '../common/WebLink';
import Island from '../gili/layout/Island';
import Surface from '../gili/layout/Surface';
import ChatList from '../left/main/ChatList'; import ChatList from '../left/main/ChatList';
import MediaStory from '../story/MediaStory'; import MediaStory from '../story/MediaStory';
import Button from '../ui/Button'; import Button from '../ui/Button';
@ -119,13 +120,13 @@ import InfiniteScroll from '../ui/InfiniteScroll';
import Link from '../ui/Link'; import Link from '../ui/Link';
import ListItem, { type MenuItemContextAction } from '../ui/ListItem'; import ListItem, { type MenuItemContextAction } from '../ui/ListItem';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import SquareTabList, { type TabWithProperties } from '../ui/SquareTabList'; import TabList, { type TabWithProperties } from '../ui/TabList';
import Transition from '../ui/Transition'; import Transition from '../ui/Transition';
import DeleteMemberModal from './DeleteMemberModal'; import DeleteMemberModal from './DeleteMemberModal';
import StarGiftCollectionList from './gifts/StarGiftCollectionList'; import StarGiftCollectionList from './gifts/StarGiftCollectionList';
import StoryAlbumList from './stories/StoryAlbumList'; import StoryAlbumList from './stories/StoryAlbumList';
import './Profile.scss'; import styles from './Profile.module.scss';
type OwnProps = { type OwnProps = {
chatId: string; chatId: string;
@ -134,6 +135,7 @@ type OwnProps = {
isMobile?: boolean; isMobile?: boolean;
isActive: boolean; isActive: boolean;
onProfileStateChange: (state: ProfileState) => void; onProfileStateChange: (state: ProfileState) => void;
onProfileExpandedChange?: (isExpanded: boolean) => void;
}; };
type StateProps = { type StateProps = {
@ -208,6 +210,7 @@ const TABS: LocalTabProps[] = [
{ type: 'gif', key: 'ProfileTabGifs' }, { type: 'gif', key: 'ProfileTabGifs' },
]; ];
const CONTENT_PANEL_SHOW_DELAY = 300;
const HIDDEN_RENDER_DELAY = 1000; const HIDDEN_RENDER_DELAY = 1000;
const INTERSECTION_THROTTLE = 500; const INTERSECTION_THROTTLE = 500;
@ -220,6 +223,26 @@ const VALID_USER_MAIN_TAB_TYPES = new Set<StringAutocomplete<ApiProfileTab>>([
const SHARED_MEDIA_TYPES = new Set<StringAutocomplete<SharedMediaType>>([ const SHARED_MEDIA_TYPES = new Set<StringAutocomplete<SharedMediaType>>([
'media', 'documents', 'links', 'audio', 'voice', 'gif', 'media', 'documents', 'links', 'audio', 'voice', 'gif',
]); ]);
const NON_ISLAND_TABS = new Set<ProfileTabType>([
'media', 'gif', 'stories', 'storiesArchive', 'previewMedia', 'gifts',
]);
const CONTENT_LIST_CLASS: Record<string, string> = {
media: styles.mediaList,
documents: styles.documentsList,
links: styles.linksList,
audio: styles.audioList,
voice: styles.voiceList,
gif: styles.gifList,
stories: styles.storiesList,
storiesArchive: styles.storiesArchiveList,
previewMedia: styles.previewMediaList,
gifts: styles.giftsList,
members: styles.membersList,
commonChats: styles.commonChatsList,
similarChannels: styles.similarChannelsList,
similarBots: styles.similarBotsList,
};
const Profile = ({ const Profile = ({
chatId, chatId,
@ -279,6 +302,7 @@ const Profile = ({
canUpdateMainTab, canUpdateMainTab,
canAutoPlayGifs, canAutoPlayGifs,
onProfileStateChange, onProfileStateChange,
onProfileExpandedChange,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
setSharedMediaSearchType, setSharedMediaSearchType,
@ -324,7 +348,9 @@ const Profile = ({
const isGeneralSavedMessages = isSavedMessages && !isSavedDialog; const isGeneralSavedMessages = isSavedMessages && !isSavedDialog;
const [isProfileExpanded, expandProfile, collapseProfile] = useFlag(); const [isProfileExpanded, expandProfile, collapseProfile] = useFlag();
const [restoreContentHeightKey, setRestoreContentHeightKey] = useState(0); useEffect(() => {
onProfileExpandedChange?.(isProfileExpanded);
}, [isProfileExpanded, onProfileExpandedChange]);
const isUser = isUserId(chatId); const isUser = isUserId(chatId);
const validMainTabTypes = isUser ? VALID_USER_MAIN_TAB_TYPES : VALID_CHANNEL_MAIN_TAB_TYPES; const validMainTabTypes = isUser ? VALID_USER_MAIN_TAB_TYPES : VALID_CHANNEL_MAIN_TAB_TYPES;
@ -438,10 +464,10 @@ const Profile = ({
setActiveTab(peerFullInfo.mainTab); // Only focus when loading full info setActiveTab(peerFullInfo.mainTab); // Only focus when loading full info
}, [peerFullInfo]); }, [peerFullInfo]);
const handleSwitchTab = useCallback((index: number) => { const handleSwitchTab = useLastCallback((index: number) => {
startAutoScrollToTabsIfNeeded(); startAutoScrollToTabsIfNeeded();
setActiveTab(tabs[index].type); setActiveTab(tabs[index].type);
}, [tabs]); });
useEffect(() => { useEffect(() => {
if (hasPreviewMediaTab && !botPreviewMedia) { if (hasPreviewMediaTab && !botPreviewMedia) {
@ -511,15 +537,15 @@ const Profile = ({
if (!isSynced) return; if (!isSynced) return;
loadCommonChats({ userId: chatId }); loadCommonChats({ userId: chatId });
}); });
const handleLoadPeerStories = useCallback(({ offsetId }: { offsetId: number }) => { const handleLoadPeerStories = useLastCallback(({ offsetId }: { offsetId: number }) => {
loadPeerProfileStories({ peerId: chatId, offsetId }); loadPeerProfileStories({ peerId: chatId, offsetId });
}, [chatId]); });
const handleLoadStoriesArchive = useCallback(({ offsetId }: { offsetId: number }) => { const handleLoadStoriesArchive = useLastCallback(({ offsetId }: { offsetId: number }) => {
loadStoriesArchive({ peerId: chatId, offsetId }); loadStoriesArchive({ peerId: chatId, offsetId });
}, [chatId]); });
const handleLoadGifts = useCallback(() => { const handleLoadGifts = useLastCallback(() => {
loadPeerSavedGifts({ peerId: chatId }); loadPeerSavedGifts({ peerId: chatId });
}, [chatId]); });
const handleLoadMoreMembers = useLastCallback(() => { const handleLoadMoreMembers = useLastCallback(() => {
if (!isSynced) return; if (!isSynced) return;
@ -574,6 +600,14 @@ const Profile = ({
similarBots, similarBots,
}); });
const shouldWrapInIsland = !NON_ISLAND_TABS.has(resultType);
useEffect(() => {
if (getMore && !viewportIds && isSynced) {
getMore({ direction: LoadMoreDirection.Backwards });
}
}, [getMore, viewportIds, resultType, isSynced]);
const shouldRenderProfileInfo = !noProfileInfo && !isSavedMessages; const shouldRenderProfileInfo = !noProfileInfo && !isSavedMessages;
const isFirstTab = tabs[0].type === resultType; const isFirstTab = tabs[0].type === resultType;
@ -589,21 +623,20 @@ const Profile = ({
const shouldShowContentPanel = (isGiftsResult && hasGiftsCollections) || (isStoriesResult && hasStoryAlbums); const shouldShowContentPanel = (isGiftsResult && hasGiftsCollections) || (isStoriesResult && hasStoryAlbums);
useEffect(() => { useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
if (hasGiftsCollections) { if (hasGiftsCollections) {
setTimeout(() => { timers.push(setTimeout(markGiftCollectionsShowed, CONTENT_PANEL_SHOW_DELAY));
markGiftCollectionsShowed();
}, CONTENT_PANEL_SHOW_DELAY);
} else { } else {
unmarkGiftCollectionsShowed(); unmarkGiftCollectionsShowed();
} }
if (hasStoryAlbums) { if (hasStoryAlbums) {
setTimeout(() => { timers.push(setTimeout(markStoryAlbumsShowed, CONTENT_PANEL_SHOW_DELAY));
markStoryAlbumsShowed();
}, CONTENT_PANEL_SHOW_DELAY);
} else { } else {
unmarkStoryAlbums(); unmarkStoryAlbums();
} }
return () => timers.forEach(clearTimeout);
}, [hasGiftsCollections, hasStoryAlbums, markGiftCollectionsShowed, markStoryAlbumsShowed]); }, [hasGiftsCollections, hasStoryAlbums, markGiftCollectionsShowed, markStoryAlbumsShowed]);
usePeerStoriesPolling(resultType === 'members' ? viewportIds as string[] : undefined); usePeerStoriesPolling(resultType === 'members' ? viewportIds as string[] : undefined);
@ -638,8 +671,6 @@ const Profile = ({
useTransitionFixes(containerRef); useTransitionFixes(containerRef);
const [cacheBuster, resetCacheBuster] = useCacheBuster();
const { observe: observeIntersectionForMedia } = useIntersectionObserver({ const { observe: observeIntersectionForMedia } = useIntersectionObserver({
rootRef: containerRef, rootRef: containerRef,
throttleMs: INTERSECTION_THROTTLE, throttleMs: INTERSECTION_THROTTLE,
@ -777,14 +808,14 @@ const Profile = ({
function renderNothingFoundGiftsWithFilter() { function renderNothingFoundGiftsWithFilter() {
return ( return (
<div className="nothing-found-gifts"> <div className={styles.nothingFoundGifts}>
<AnimatedIconWithPreview <AnimatedIconWithPreview
size={160} size={160}
tgsUrl={LOCAL_TGS_URLS.SearchingDuck} tgsUrl={LOCAL_TGS_URLS.SearchingDuck}
nonInteractive nonInteractive
noLoop noLoop
/> />
<div className="description"> <div className={styles.description}>
{lang('GiftSearchEmpty')} {lang('GiftSearchEmpty')}
</div> </div>
<Link <Link
@ -797,20 +828,77 @@ const Profile = ({
); );
} }
const shouldWrapInInfiniteScroll = shouldWrapInIsland && resultType !== 'dialogs';
function wrapInIsland(content: TeactNode, className?: string) {
if (!shouldWrapInIsland) return content;
const inner = shouldWrapInInfiniteScroll ? (
<InfiniteScroll
items={canRenderContent ? viewportIds : undefined}
itemSelector={`.${CONTENT_LIST_CLASS[resultType]} > .scroll-item`}
preloadBackwards={canRenderContent
? (resultType === 'members' ? MEMBERS_SLICE : SHARED_MEDIA_SLICE) : 0}
onLoadMore={getMore}
scrollContainerClosest=".Profile"
sensitiveArea={PROFILE_SENSITIVE_AREA}
noScrollRestore
noFastList
>
{content}
</InfiniteScroll>
) : content;
return (
<div className={styles.sharedMediaIslandContainer}>
<Island className={buildClassName(styles.sharedMediaIsland, 'custom-scroll', className)}>
{inner}
</Island>
</div>
);
}
function renderContent() { function renderContent() {
if (resultType === 'dialogs') { if (resultType === 'dialogs') {
return ( return wrapInIsland(
<ChatList className="saved-dialogs" folderType="saved" isActive /> <ChatList
className={styles.savedDialogs}
folderType="saved"
isActive
noAbsolutePositioning
noVirtualization
noScrollRestore
noFastList
scrollContainerClosest=".Profile"
/>,
styles.savedDialogsIsland,
); );
} }
const noContent = (!viewportIds && !botPreviewMedia) || !canRenderContent || !messagesById; const needsMessages = resultType === 'media' || resultType === 'gif' || resultType === 'documents'
|| resultType === 'links' || resultType === 'audio' || resultType === 'voice';
const noContent = (!viewportIds && !botPreviewMedia) || !canRenderContent || (needsMessages && !messagesById);
const noSpinner = isFirstTab && !canRenderContent; const noSpinner = isFirstTab && !canRenderContent;
if (shouldWrapInIsland) {
return renderSpinnerOrContent(noContent, noSpinner);
}
return ( return (
<div> <div className={styles.sharedMediaIslandContainer}>
{renderCategories()} {renderCategories()}
{renderSpinnerOrContent(noContent, noSpinner)} <InfiniteScroll
itemSelector={`.${CONTENT_LIST_CLASS[resultType]} > .scroll-item`}
items={canRenderContent ? viewportIds : undefined}
sensitiveArea={PROFILE_SENSITIVE_AREA}
preloadBackwards={canRenderContent ? SHARED_MEDIA_SLICE : 0}
scrollContainerClosest=".Profile"
noScrollRestore
onLoadMore={getMore}
noFastList
>
{renderSpinnerOrContent(noContent, noSpinner)}
</InfiniteScroll>
</div> </div>
); );
} }
@ -820,9 +908,9 @@ const Profile = ({
return ( return (
<div <div
className={buildClassName( className={buildClassName(
'contentCategoriesPanel', styles.contentCategoriesPanel,
!shouldShowContentPanel && 'hiddenPanel', !shouldShowContentPanel && styles.hiddenPanel,
isGiftCollectionsShowed && 'noTransition', isGiftCollectionsShowed && styles.noTransition,
)} )}
> >
<StarGiftCollectionList peerId={chatId} /> <StarGiftCollectionList peerId={chatId} />
@ -834,9 +922,9 @@ const Profile = ({
return ( return (
<div <div
className={buildClassName( className={buildClassName(
'contentCategoriesPanel', styles.contentCategoriesPanel,
!shouldShowContentPanel && 'hiddenPanel', !shouldShowContentPanel && styles.hiddenPanel,
isStoryAlbumsShowed && 'noTransition', isStoryAlbumsShowed && styles.noTransition,
)} )}
> >
<StoryAlbumList peerId={chatId} /> <StoryAlbumList peerId={chatId} />
@ -853,7 +941,7 @@ const Profile = ({
return ( return (
<div <div
className="content empty-list" className={buildClassName(styles.content, styles.emptyList)}
> >
{!noSpinner && !forceRenderHiddenMembers && <Spinner />} {!noSpinner && !forceRenderHiddenMembers && <Spinner />}
{forceRenderHiddenMembers && <NothingFound text={lang('ChatMemberListNoAccess')} />} {forceRenderHiddenMembers && <NothingFound text={lang('ChatMemberListNoAccess')} />}
@ -903,7 +991,7 @@ const Profile = ({
} }
return ( return (
<div className="content empty-list"> <div className={buildClassName(styles.content, styles.emptyList)}>
<NothingFound text={text} /> <NothingFound text={text} />
</div> </div>
); );
@ -916,18 +1004,18 @@ const Profile = ({
const noTransition = resultType === 'gifts' ? isGiftCollectionsShowed const noTransition = resultType === 'gifts' ? isGiftCollectionsShowed
: resultType === 'stories' ? isStoryAlbumsShowed : false; : resultType === 'stories' ? isStoryAlbumsShowed : false;
return ( const contentEl = (
<div <div
className={buildClassName( className={buildClassName(
`content ${resultType}-list`, styles.content,
shouldShowContentPanel && 'showContentPanel', CONTENT_LIST_CLASS[resultType],
noTransition && 'noTransition', shouldShowContentPanel && styles.showContentPanel,
noTransition && styles.noTransition,
)} )}
dir={lang.isRtl && (resultType === 'media' || resultType === 'gif') ? 'rtl' : undefined} dir={lang.isRtl && (resultType === 'media' || resultType === 'gif') ? 'rtl' : undefined}
teactFastList
> >
{resultType === 'media' || resultType === 'gif' ? ( {resultType === 'media' || resultType === 'gif' ? (
(viewportIds as number[]).map((id) => messagesById[id] && ( (viewportIds as number[]).filter((id) => Boolean(messagesById[id])).map((id) => (
<Media <Media
key={id} key={id}
message={messagesById[id]} message={messagesById[id]}
@ -938,16 +1026,16 @@ const Profile = ({
/> />
)) ))
) : (resultType === 'stories' || resultType === 'storiesArchive') ? ( ) : (resultType === 'stories' || resultType === 'storiesArchive') ? (
(viewportIds as number[]).map((id, i) => storyByIds?.[id] && ( (viewportIds as number[]).filter((id) => Boolean(storyByIds?.[id])).map((id, i) => (
<MediaStory <MediaStory
teactOrderKey={i} teactOrderKey={i}
key={`${resultType}_${id}`} key={`${resultType}_${id}`}
story={storyByIds[id]} story={storyByIds![id]}
isArchive={resultType === 'storiesArchive'} isArchive={resultType === 'storiesArchive'}
/> />
)) ))
) : resultType === 'documents' ? ( ) : resultType === 'documents' ? (
(viewportIds as number[]).map((id) => messagesById[id] && ( (viewportIds as number[]).filter((id) => Boolean(messagesById[id])).map((id) => (
<Document <Document
key={id} key={id}
id={`shared-media${getMessageHtmlId(id)}`} id={`shared-media${getMessageHtmlId(id)}`}
@ -964,7 +1052,7 @@ const Profile = ({
/> />
)) ))
) : resultType === 'links' ? ( ) : resultType === 'links' ? (
(viewportIds as number[]).map((id) => messagesById[id] && ( (viewportIds as number[]).filter((id) => Boolean(messagesById[id])).map((id) => (
<WebLink <WebLink
key={id} key={id}
message={messagesById[id]} message={messagesById[id]}
@ -974,7 +1062,7 @@ const Profile = ({
/> />
)) ))
) : resultType === 'audio' ? ( ) : resultType === 'audio' ? (
(viewportIds as number[]).map((id) => messagesById[id] && ( (viewportIds as number[]).filter((id) => Boolean(messagesById[id])).map((id) => (
<Audio <Audio
key={id} key={id}
theme={theme} theme={theme}
@ -989,13 +1077,12 @@ const Profile = ({
/> />
)) ))
) : resultType === 'voice' ? ( ) : resultType === 'voice' ? (
(viewportIds as number[]).map((id) => { (viewportIds as number[]).filter((id) => Boolean(messagesById[id])).map((id) => {
const global = getGlobal(); const global = getGlobal();
const message = messagesById[id]; const message = messagesById[id];
if (!message) return undefined;
const media = selectMessageDownloadableMedia(global, message)!; const media = selectMessageDownloadableMedia(global, message)!;
return messagesById[id] && ( return (
<Audio <Audio
key={id} key={id}
theme={theme} theme={theme}
@ -1073,14 +1160,14 @@ const Profile = ({
{!isCurrentUserPremium && ( {!isCurrentUserPremium && (
<> <>
<Button <Button
className="show-more-channels" className={styles.showMoreChannels}
onClick={() => openPremiumModal()} onClick={() => openPremiumModal()}
iconName="unlock-badge" iconName="unlock-badge"
iconAlignment="end" iconAlignment="end"
> >
{oldLang('UnlockSimilar')} {oldLang('UnlockSimilar')}
</Button> </Button>
<div className="more-similar"> <div className={styles.moreSimilar}>
{renderText(oldLang('MoreSimilarText', limitSimilarPeers), ['simple_markdown'])} {renderText(oldLang('MoreSimilarText', limitSimilarPeers), ['simple_markdown'])}
</div> </div>
</> </>
@ -1107,10 +1194,10 @@ const Profile = ({
))} ))}
{!isCurrentUserPremium && ( {!isCurrentUserPremium && (
<> <>
<Button className="show-more-bots" onClick={() => openPremiumModal()} iconName="unlock-badge"> <Button className={styles.showMoreBots} onClick={() => openPremiumModal()} iconName="unlock-badge">
{lang('UnlockMoreSimilarBots')} {lang('UnlockMoreSimilarBots')}
</Button> </Button>
<div className="more-similar"> <div className={styles.moreSimilar}>
{renderText(lang('MoreSimilarBotsDescription', { count: limitSimilarPeers }, { {renderText(lang('MoreSimilarBotsDescription', { count: limitSimilarPeers }, {
withNodes: true, withNodes: true,
withMarkdown: true, withMarkdown: true,
@ -1126,7 +1213,7 @@ const Profile = ({
<SavedGift <SavedGift
peerId={chatId} peerId={chatId}
key={getSavedGiftKey(gift)} key={getSavedGiftKey(gift)}
className="saved-gift" className={styles.savedGift}
style={createVtnStyle(getSavedGiftKey(gift))} style={createVtnStyle(getSavedGiftKey(gift))}
gift={gift} gift={gift}
observeIntersection={observeIntersectionForMedia} observeIntersection={observeIntersectionForMedia}
@ -1136,6 +1223,8 @@ const Profile = ({
) : undefined} ) : undefined}
</div> </div>
); );
return wrapInIsland(contentEl);
} }
const shouldUseTransitionForContent = resultType === 'stories' || resultType === 'gifts'; const shouldUseTransitionForContent = resultType === 'stories' || resultType === 'gifts';
@ -1149,13 +1238,9 @@ const Profile = ({
return 0; return 0;
})(); })();
const handleOnStop = useLastCallback(() => {
setRestoreContentHeightKey(restoreContentHeightKey + 1);
});
function renderProfileInfo(peerId: string, isReady: boolean) { function renderProfileInfo(peerId: string, isReady: boolean) {
return ( return (
<div className="profile-info"> <div className={buildClassName(styles.profileInfo, 'profile-info')}>
<ProfileInfo <ProfileInfo
isExpanded={isProfileExpanded} isExpanded={isProfileExpanded}
peerId={peerId} peerId={peerId}
@ -1167,6 +1252,9 @@ const Profile = ({
chatOrUserId={profileId} chatOrUserId={profileId}
isSavedDialog={isSavedDialog} isSavedDialog={isSavedDialog}
isOwnProfile={isOwnProfile} isOwnProfile={isOwnProfile}
withIslands
className={styles.chatExtraBlock}
style={createVtnStyle('chatExtraBlock', true)}
/> />
</div> </div>
); );
@ -1180,22 +1268,15 @@ const Profile = ({
if (shouldUseTransitionForContent) { if (shouldUseTransitionForContent) {
return ( return (
<Transition <Transition
className={`${resultType}-list`} className={CONTENT_LIST_CLASS[resultType]}
activeKey={contentTransitionKey} activeKey={contentTransitionKey}
name={resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)} name={resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)}
shouldCleanup contentSelector={`.Transition > .Transition_slide-active > .${styles.content}`}
shouldRestoreHeight
restoreHeightKey={restoreContentHeightKey}
contentSelector=".Transition > .Transition_slide-active > .content"
> >
<Transition <Transition
activeKey={isSpinner ? 0 : 1} activeKey={isSpinner ? 0 : 1}
name="fade" name="fade"
shouldCleanup contentSelector={`.${styles.content}`}
shouldRestoreHeight
restoreHeightKey={restoreContentHeightKey}
contentSelector=".content"
onStop={handleOnStop}
> >
{baseContent} {baseContent}
</Transition> </Transition>
@ -1207,34 +1288,18 @@ const Profile = ({
<Transition <Transition
activeKey={isSpinner ? 0 : 1} activeKey={isSpinner ? 0 : 1}
name="fade" name="fade"
shouldCleanup
shouldRestoreHeight
> >
{baseContent} {baseContent}
</Transition> </Transition>
); );
} }
const activeListSelector = `.shared-media-transition > .Transition_slide-active`;
// eslint-disable-next-line @stylistic/max-len
const nestedSelector = `${activeListSelector} > .Transition > .Transition_slide-active > .Transition > .Transition_slide-active`;
const itemSelector = !shouldUseTransitionForContent
? `${activeListSelector} .${resultType}-list > .scroll-item`
: `${nestedSelector} > .${resultType}-list > .scroll-item`;
return ( return (
<InfiniteScroll <Surface
ref={containerRef} ref={containerRef}
className="Profile custom-scroll" scrollable
itemSelector={itemSelector} noPadding
items={canRenderContent ? viewportIds : undefined} className={buildClassName(styles.root, 'Profile', isGeneralSavedMessages && 'is-saved-messages')}
cacheBuster={cacheBuster}
sensitiveArea={PROFILE_SENSITIVE_AREA}
preloadBackwards={canRenderContent ? (resultType === 'members' ? MEMBERS_SLICE : SHARED_MEDIA_SLICE) : 0}
// To prevent scroll jumps caused by reordering member list
noScrollRestoreOnTop
noFastList
onLoadMore={getMore}
onScroll={handleScroll} onScroll={handleScroll}
> >
{!noProfileInfo && !isSavedMessages && ( {!noProfileInfo && !isSavedMessages && (
@ -1244,33 +1309,42 @@ const Profile = ({
) )
)} )}
{!isRestricted && ( {!isRestricted && (
<div <>
className="shared-media" <div
style={createVtnStyle('sharedMedia')} className={buildClassName(styles.sharedMediaTabs, 'shared-media-tabs')}
> style={createVtnStyle('sharedMediaTabs')}
<Transition
ref={transitionRef}
name={shouldSkipTransitionRef.current ? 'none'
: resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)}
activeKey={activeKey}
renderCount={tabs.length}
shouldRestoreHeight
className="shared-media-transition"
onStop={resetCacheBuster}
restoreHeightKey={shouldUseTransitionForContent ? restoreContentHeightKey : undefined}
contentSelector={shouldUseTransitionForContent
? '.Transition > .Transition_slide-active > .Transition > .Transition_slide-active > .content'
: undefined}
> >
{renderContent()} <TabList
</Transition> activeTab={activeTabIndex}
<SquareTabList activeTab={activeTabIndex} tabs={tabs} onSwitchTab={handleSwitchTab} /> tabs={tabs}
</div> onSwitchTab={handleSwitchTab}
/>
</div>
<div
className={styles.sharedMedia}
style={createVtnStyle('sharedMedia')}
>
<Transition
ref={transitionRef}
name={shouldSkipTransitionRef.current ? 'none'
: resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)}
activeKey={activeKey}
renderCount={tabs.length}
className="shared-media-transition"
contentSelector={shouldUseTransitionForContent
? `.Transition > .Transition_slide-active > .Transition > .Transition_slide-active > .${styles.content}`
: undefined}
>
{renderContent()}
</Transition>
</div>
</>
)} )}
{canAddMembers && ( {canAddMembers && (
<FloatingActionButton <FloatingActionButton
className={buildClassName(!isActive && 'hidden')} className={buildClassName(!isActive && styles.hidden)}
style={createVtnStyle('profileFab')}
isShown={canRenderContent} isShown={canRenderContent}
onClick={handleNewMemberDialogOpen} onClick={handleNewMemberDialogOpen}
ariaLabel={oldLang('lng_channel_add_users')} ariaLabel={oldLang('lng_channel_add_users')}
@ -1284,7 +1358,7 @@ const Profile = ({
onClose={handleDeleteMembersModalClose} onClose={handleDeleteMembersModalClose}
/> />
)} )}
</InfiniteScroll> </Surface>
); );
}; };

View File

@ -10,7 +10,7 @@
padding-right: env(safe-area-inset-right); padding-right: env(safe-area-inset-right);
border-left: 1px solid var(--color-borders); border-left: 1px solid var(--color-borders);
background: var(--color-background); background: var(--color-background-secondary);
transition: transform var(--layer-transition); transition: transform var(--layer-transition);

View File

@ -11,6 +11,7 @@ import {
selectAreActiveChatsLoaded, selectAreActiveChatsLoaded,
selectCurrentMessageList, selectCurrentMessageList,
selectIsChatWithSelf, selectIsChatWithSelf,
selectPeerHasProfileBackground,
selectRightColumnContentKey, selectRightColumnContentKey,
selectTabState, selectTabState,
} from '../../global/selectors'; } from '../../global/selectors';
@ -59,6 +60,7 @@ type StateProps = {
isSavedMessages?: boolean; isSavedMessages?: boolean;
isSavedDialog?: boolean; isSavedDialog?: boolean;
isOwnProfile?: boolean; isOwnProfile?: boolean;
hasProfileBackground?: boolean;
}; };
const ANIMATION_DURATION = 450 + ANIMATION_END_DELAY; const ANIMATION_DURATION = 450 + ANIMATION_END_DELAY;
@ -85,6 +87,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
isSavedMessages, isSavedMessages,
isSavedDialog, isSavedDialog,
isOwnProfile, isOwnProfile,
hasProfileBackground,
}) => { }) => {
const { const {
toggleChatInfo, toggleChatInfo,
@ -116,6 +119,8 @@ const RightColumn: FC<OwnProps & StateProps> = ({
const [managementScreen, setManagementScreen] = useState<ManagementScreens>(ManagementScreens.Initial); const [managementScreen, setManagementScreen] = useState<ManagementScreens>(ManagementScreens.Initial);
const [selectedChatMemberId, setSelectedChatMemberId] = useState<string | undefined>(); const [selectedChatMemberId, setSelectedChatMemberId] = useState<string | undefined>();
const [isPromotedByCurrentUser, setIsPromotedByCurrentUser] = useState<boolean | undefined>(); const [isPromotedByCurrentUser, setIsPromotedByCurrentUser] = useState<boolean | undefined>();
const [isProfileExpanded, setIsProfileExpanded] = useState(false);
const [isProfileScrolled, setIsProfileScrolled] = useState(false);
const isScrolledDown = profileState !== ProfileState.Profile; const isScrolledDown = profileState !== ProfileState.Profile;
const isOpen = contentKey !== undefined; const isOpen = contentKey !== undefined;
@ -134,6 +139,14 @@ const RightColumn: FC<OwnProps & StateProps> = ({
const isEditingTopic = contentKey === RightColumnContent.EditTopic; const isEditingTopic = contentKey === RightColumnContent.EditTopic;
const isOverlaying = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN; const isOverlaying = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN;
const headerBackground: 'regular' | 'secondary' = (() => {
if (isSavedMessages) return 'secondary';
if (!isProfile) return 'regular';
if (isScrolledDown) return 'secondary';
if (!isProfileScrolled && !isProfileExpanded && !hasProfileBackground) return 'secondary';
return 'regular';
})();
const [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen); const [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen);
const renderingContentKey = useCurrentOrPrev(contentKey, true, !isChatSelected) ?? -1; const renderingContentKey = useCurrentOrPrev(contentKey, true, !isChatSelected) ?? -1;
@ -141,8 +154,27 @@ const RightColumn: FC<OwnProps & StateProps> = ({
useScrollNotch({ useScrollNotch({
containerRef, containerRef,
selector: ':scope .custom-scroll, :scope .panel-content', selector: ':scope .custom-scroll, :scope .panel-content',
shouldHideTopNotch: isSavedMessages || (isProfile && isScrolledDown),
}, [contentKey, managementScreen, chatId, threadId]); }, [contentKey, managementScreen, chatId, threadId]);
useEffect(() => {
if (!isProfile || isScrolledDown || isSavedMessages) {
setIsProfileScrolled(false);
return undefined;
}
const scrollEl = containerRef.current?.querySelector<HTMLElement>('.custom-scroll');
if (!scrollEl) return undefined;
const handleProfileScroll = () => {
setIsProfileScrolled(scrollEl.scrollTop > 1);
};
handleProfileScroll();
scrollEl.addEventListener('scroll', handleProfileScroll, { passive: true });
return () => scrollEl.removeEventListener('scroll', handleProfileScroll);
}, [chatId, threadId, isProfile, isScrolledDown, isSavedMessages]);
const close = useLastCallback((shouldScrollUp = true) => { const close = useLastCallback((shouldScrollUp = true) => {
switch (contentKey) { switch (contentKey) {
case RightColumnContent.AddingMembers: case RightColumnContent.AddingMembers:
@ -320,6 +352,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
isMobile={isMobile} isMobile={isMobile}
isActive={isOpen && isActive} isActive={isOpen && isActive}
onProfileStateChange={setProfileState} onProfileStateChange={setProfileState}
onProfileExpandedChange={setIsProfileExpanded}
/> />
); );
case RightColumnContent.Management: case RightColumnContent.Management:
@ -376,6 +409,7 @@ const RightColumn: FC<OwnProps & StateProps> = ({
threadId={threadId} threadId={threadId}
isColumnOpen={isOpen} isColumnOpen={isOpen}
isProfile={isProfile} isProfile={isProfile}
headerBackground={headerBackground}
isManagement={isManagement} isManagement={isManagement}
isStatistics={isStatistics} isStatistics={isStatistics}
isBoostStatistics={isBoostStatistics} isBoostStatistics={isBoostStatistics}
@ -439,6 +473,7 @@ export default memo(withGlobal<OwnProps>(
isSavedMessages, isSavedMessages,
isSavedDialog, isSavedDialog,
isOwnProfile, isOwnProfile,
hasProfileBackground: chatId ? selectPeerHasProfileBackground(global, chatId) : undefined,
}; };
}, },
)(RightColumn)); )(RightColumn));

View File

@ -9,7 +9,13 @@
height: var(--header-height); height: var(--header-height);
padding: 0.5rem 0.8125rem; padding: 0.5rem 0.8125rem;
background: var(--color-background); background-color: var(--color-background);
transition: background-color 150ms;
&.secondary {
background-color: var(--color-background-secondary);
}
@include mixins.with-vt-type('rightColumn'); @include mixins.with-vt-type('rightColumn');

View File

@ -67,6 +67,7 @@ type OwnProps = {
isCreatingTopic?: boolean; isCreatingTopic?: boolean;
isEditingTopic?: boolean; isEditingTopic?: boolean;
isAddingChatMembers?: boolean; isAddingChatMembers?: boolean;
headerBackground?: 'regular' | 'secondary';
profileState?: ProfileState; profileState?: ProfileState;
managementScreen?: ManagementScreens; managementScreen?: ManagementScreens;
onClose: (shouldScrollUp?: boolean) => void; onClose: (shouldScrollUp?: boolean) => void;
@ -158,6 +159,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isCreatingTopic, isCreatingTopic,
isEditingTopic, isEditingTopic,
isAddingChatMembers, isAddingChatMembers,
headerBackground,
profileState, profileState,
managementScreen, managementScreen,
canAddContact, canAddContact,
@ -266,6 +268,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
const isSecondaryBackground = headerBackground === 'secondary';
const contentKey = isProfile ? ( const contentKey = isProfile ? (
profileState === ProfileState.Profile ? ( profileState === ProfileState.Profile ? (
HeaderContent.Profile HeaderContent.Profile
@ -703,7 +707,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
return ( return (
<div <div
className="RightHeader" className={buildClassName('RightHeader', isSecondaryBackground && 'secondary')}
data-tauri-drag-region={IS_TAURI && IS_MAC_OS ? true : undefined} data-tauri-drag-region={IS_TAURI && IS_MAC_OS ? true : undefined}
style={createVtnStyle('rightHeader', true)} style={createVtnStyle('rightHeader', true)}
> >

View File

@ -1,3 +1,3 @@
.tabList { .tabList {
margin-top: 0.5rem; margin-inline: 0.5rem;
} }

View File

@ -16,6 +16,11 @@ const runThrottledForScroll = throttle((cb) => cb(), 250, false);
let isScrollingProgrammatically = false; let isScrollingProgrammatically = false;
function getTabsNaturalTop(container: HTMLElement): number {
const profileInfo = container.querySelector<HTMLElement>('.profile-info');
return profileInfo ? profileInfo.offsetHeight : 0;
}
export default function useProfileState({ export default function useProfileState({
containerRef, containerRef,
tabType, tabType,
@ -37,9 +42,9 @@ export default function useProfileState({
useEffectWithPrevDeps(([prevTabType]) => { useEffectWithPrevDeps(([prevTabType]) => {
if ((prevTabType && prevTabType !== tabType && allowAutoScrollToTabs) || (tabType && forceScrollProfileTab)) { if ((prevTabType && prevTabType !== tabType && allowAutoScrollToTabs) || (tabType && forceScrollProfileTab)) {
const container = containerRef.current!; const container = containerRef.current!;
const tabsEl = container.querySelector<HTMLDivElement>('.SquareTabList')!; const tabsEl = container.querySelector<HTMLDivElement>('.shared-media-tabs')!;
handleStopAutoScrollToTabs(); handleStopAutoScrollToTabs();
if (container.scrollTop < tabsEl.offsetTop) { if (container.scrollTop < getTabsNaturalTop(container)) {
onProfileStateChange(getStateFromTabType(tabType)); onProfileStateChange(getStateFromTabType(tabType));
isScrollingProgrammatically = true; isScrollingProgrammatically = true;
animateScroll({ animateScroll({
@ -69,8 +74,8 @@ export default function useProfileState({
return; return;
} }
const tabListEl = container.querySelector<HTMLDivElement>('.SquareTabList'); const tabsEl = container.querySelector<HTMLDivElement>('.shared-media-tabs');
if (!tabListEl || tabListEl.offsetTop > container.scrollTop) { if (!tabsEl || getTabsNaturalTop(container) > container.scrollTop) {
return; return;
} }
@ -94,13 +99,12 @@ export default function useProfileState({
return; return;
} }
const tabListEl = container.querySelector<HTMLDivElement>('.SquareTabList'); if (!container.querySelector('.shared-media-tabs')) {
if (!tabListEl) {
return; return;
} }
let state: ProfileState = ProfileState.Profile; let state: ProfileState = ProfileState.Profile;
if (Math.ceil(container.scrollTop) >= tabListEl.offsetTop) { if (Math.ceil(container.scrollTop) >= getTabsNaturalTop(container)) {
state = getStateFromTabType(tabType); state = getStateFromTabType(tabType);
} }

View File

@ -12,6 +12,8 @@ import sortChatIds from '../../common/helpers/sortChatIds';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import useSyncEffect from '../../../hooks/useSyncEffect'; import useSyncEffect from '../../../hooks/useSyncEffect';
const SHARED_MEDIA_TYPES: SharedMediaType[] = ['media', 'documents', 'links', 'audio', 'voice', 'gif'];
export default function useProfileViewportIds({ export default function useProfileViewportIds({
loadMoreMembers, loadMoreMembers,
loadCommonChats, loadCommonChats,
@ -59,7 +61,8 @@ export default function useProfileViewportIds({
similarChannels?: string[]; similarChannels?: string[];
similarBots?: string[]; similarBots?: string[];
}) { }) {
const resultType = tabType === 'members' || !mediaSearchType ? tabType : mediaSearchType; const resultType = mediaSearchType && SHARED_MEDIA_TYPES.includes(tabType as SharedMediaType)
? mediaSearchType : tabType;
const memberIds = useMemo(() => { const memberIds = useMemo(() => {
if (!groupChatMembers || !usersById || !userStatusesById) { if (!groupChatMembers || !usersById || !userStatusesById) {
@ -218,7 +221,7 @@ function useInfiniteScrollForLoadableItems<ListId extends string | number>(
handleLoadMore, handleLoadMore,
itemIds, itemIds,
undefined, undefined,
MEMBERS_SLICE, itemIds?.length || MEMBERS_SLICE,
); );
const isOnTop = !viewportIds || !itemIds || viewportIds[0] === itemIds[0]; const isOnTop = !viewportIds || !itemIds || viewportIds[0] === itemIds[0];
@ -250,11 +253,16 @@ function useInfiniteScrollForSharedMedia(
} }
}, [chatMessages, foundIds, currentResultType, forSharedMediaType]); }, [chatMessages, foundIds, currentResultType, forSharedMediaType]);
const msgLen = messageIdsRef.current?.length ?? 0;
const listSlice = forSharedMediaType === 'media'
? Math.max(SHARED_MEDIA_SLICE, msgLen)
: Math.max(MESSAGE_SEARCH_SLICE, msgLen);
const [viewportIds, getMore] = useInfiniteScroll( const [viewportIds, getMore] = useInfiniteScroll(
handleLoadMore, handleLoadMore,
messageIdsRef.current, messageIdsRef.current,
undefined, undefined,
forSharedMediaType === 'media' ? SHARED_MEDIA_SLICE : MESSAGE_SEARCH_SLICE, listSlice,
); );
const isOnTop = !viewportIds || !messageIdsRef.current || viewportIds[0] === messageIdsRef.current[0]; const isOnTop = !viewportIds || !messageIdsRef.current || viewportIds[0] === messageIdsRef.current[0];

View File

@ -1,7 +1,7 @@
import type { ElementRef } from '../../../lib/teact/teact'; import type { ElementRef } from '../../../lib/teact/teact';
import { useEffect } from '../../../lib/teact/teact'; import { useEffect } from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom';
export default function useTransitionFixes( export default function useTransitionFixes(
containerRef: ElementRef<HTMLDivElement>, containerRef: ElementRef<HTMLDivElement>,
@ -10,16 +10,23 @@ export default function useTransitionFixes(
// Set `min-height` for shared media container to prevent jumping when switching tabs // Set `min-height` for shared media container to prevent jumping when switching tabs
useEffect(() => { useEffect(() => {
function setMinHeight() { function setMinHeight() {
const container = containerRef.current!; requestMeasure(() => {
const transitionEl = container.querySelector<HTMLDivElement>(transitionElSelector); const container = containerRef.current;
const tabsEl = container.querySelector<HTMLDivElement>('.SquareTabList'); if (!container) return;
if (transitionEl && tabsEl) { const transitionEl = container.querySelector<HTMLDivElement>(transitionElSelector);
const newHeight = container.clientHeight - tabsEl.offsetHeight; const tabsEl = container.querySelector<HTMLDivElement>('.shared-media-tabs');
const sharedMediaEl = transitionEl?.parentElement;
if (!transitionEl || !tabsEl) return;
const sharedMediaMargin = sharedMediaEl
? parseInt(getComputedStyle(sharedMediaEl).marginTop, 10) || 0 : 0;
const tabsMarginTop = parseInt(getComputedStyle(tabsEl).marginTop, 10) || 0;
const newHeight = container.clientHeight - tabsEl.offsetHeight - tabsMarginTop - sharedMediaMargin;
requestMutation(() => { requestMutation(() => {
transitionEl.style.minHeight = `${newHeight}px`; transitionEl.style.minHeight = `${newHeight}px`;
}); });
} });
} }
setMinHeight(); setMinHeight();

View File

@ -1,3 +1,3 @@
.tabList { .tabList {
margin-block: 0.5rem; margin-inline: 0.5rem;
} }

View File

@ -14,6 +14,7 @@ type OwnProps = {
isShown: boolean; isShown: boolean;
iconName: IconName; iconName: IconName;
className?: string; className?: string;
style?: string;
color?: ButtonProps['color']; color?: ButtonProps['color'];
ariaLabel?: ButtonProps['ariaLabel']; ariaLabel?: ButtonProps['ariaLabel'];
disabled?: boolean; disabled?: boolean;
@ -25,6 +26,7 @@ const FloatingActionButton = ({
isShown, isShown,
iconName, iconName,
className, className,
style,
color = 'primary', color = 'primary',
ariaLabel, ariaLabel,
disabled, disabled,
@ -42,6 +44,7 @@ const FloatingActionButton = ({
return ( return (
<Button <Button
className={buttonClassName} className={buttonClassName}
style={style}
color={color} color={color}
round round
disabled={disabled} disabled={disabled}

View File

@ -1,17 +1,16 @@
import type { ElementRef, TeactNode } from '../../lib/teact/teact'; import type { ElementRef, TeactNode } from '../../lib/teact/teact';
import { memo, useEffect, useRef } from '../../lib/teact/teact'; import { memo, useRef } from '../../lib/teact/teact';
import type { ApiMessageEntityCustomEmoji } from '../../api/types'; import type { ApiMessageEntityCustomEmoji } from '../../api/types';
import type { IconName } from '../../types/icons'; import type { IconName } from '../../types/icons';
import type { MenuItemContextAction } from './ListItem'; import type { MenuItemContextAction } from './ListItem';
import animateHorizontalScroll from '../../util/animateHorizontalScroll';
import { IS_ANDROID, IS_IOS } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import useScrollToActiveTab from '../../hooks/useScrollToActiveTab';
import Tab from './Tab'; import Tab from './Tab';
@ -39,10 +38,6 @@ type OwnProps = {
onSwitchTab: (index: number) => void; onSwitchTab: (index: number) => void;
}; };
const TAB_SCROLL_THRESHOLD_PX = 16;
// Should match duration from `--slide-transition` CSS variable
const SCROLL_DURATION = IS_IOS ? 450 : IS_ANDROID ? 400 : 300;
const SquareTabList = ({ const SquareTabList = ({
tabs, tabs,
activeTab, activeTab,
@ -59,30 +54,7 @@ const SquareTabList = ({
const lang = useLang(); const lang = useLang();
useHorizontalScroll(containerRef, undefined, true); useHorizontalScroll(containerRef, undefined, true);
useScrollToActiveTab(containerRef, activeTab);
// Scroll container to place active tab in the center
useEffect(() => {
const container = containerRef.current!;
const { scrollWidth, offsetWidth, scrollLeft } = container;
if (scrollWidth <= offsetWidth) {
return;
}
const activeTabElement = container.childNodes[activeTab] as HTMLElement | null;
if (!activeTabElement) {
return;
}
const { offsetLeft: activeTabOffsetLeft, offsetWidth: activeTabOffsetWidth } = activeTabElement;
const newLeft = activeTabOffsetLeft - (offsetWidth / 2) + (activeTabOffsetWidth / 2);
// Prevent scrolling by only a couple of pixels, which doesn't look smooth
if (Math.abs(newLeft - scrollLeft) < TAB_SCROLL_THRESHOLD_PX) {
return;
}
animateHorizontalScroll(container, newLeft, SCROLL_DURATION);
}, [activeTab, containerRef]);
return ( return (
<div <div

View File

@ -24,7 +24,7 @@
opacity: 0; opacity: 0;
background-color: var(--color-background); background-color: var(--color-background);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05); box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
transition: opacity 150ms; transition: opacity 150ms;
@ -48,11 +48,12 @@
} }
.activeIndicator { .activeIndicator {
pointer-events: none;
will-change: clip-path; will-change: clip-path;
isolation: isolate; isolation: isolate;
position: absolute; position: absolute;
z-index: 1; z-index: 4;
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
@ -117,4 +118,29 @@
.lockIcon { .lockIcon {
font-size: 0.875rem; font-size: 0.875rem;
} }
.withFadeMask {
position: relative;
z-index: 4;
}
.fadeMaskWrapper {
position: relative;
flex-shrink: 0;
&::after {
pointer-events: none;
content: "";
position: absolute;
z-index: 3;
top: 50%;
left: 0;
width: 100%;
height: var(--fade-mask-height, 50%);
background: linear-gradient(to bottom, var(--fade-mask-color, var(--color-background)) 0%, transparent 100%);
}
}
} }

View File

@ -1,17 +1,26 @@
import type { TeactNode } from '../../lib/teact/teact';
import { memo, useEffect, useRef, useState } from '../../lib/teact/teact'; import { memo, useEffect, useRef, useState } from '../../lib/teact/teact';
import type { IAnchorPosition } from '../../types';
import type { MenuItemContextAction } from './ListItem';
import type { TabWithProperties } from './SquareTabList'; import type { TabWithProperties } from './SquareTabList';
export type { TabWithProperties }; export type { TabWithProperties };
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText';
import useFlag from '../../hooks/useFlag';
import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useResizeObserver from '../../hooks/useResizeObserver'; import useResizeObserver from '../../hooks/useResizeObserver';
import useScrollToActiveTab from '../../hooks/useScrollToActiveTab';
import CustomEmoji from '../common/CustomEmoji'; import CustomEmoji from '../common/CustomEmoji';
import Icon from '../common/icons/Icon'; import Icon from '../common/icons/Icon';
import Menu from './Menu';
import MenuItem from './MenuItem';
import MenuSeparator from './MenuSeparator';
import styles from './TabList.module.scss'; import styles from './TabList.module.scss';
@ -26,7 +35,10 @@ type OwnProps = {
centered?: boolean; centered?: boolean;
stretched?: boolean; stretched?: boolean;
itemAlignment?: 'vertical' | 'horizontal'; itemAlignment?: 'vertical' | 'horizontal';
withFadeMask?: boolean;
fadeMaskClassName?: string;
onSwitchTab: (index: number) => void; onSwitchTab: (index: number) => void;
renderExtra?: (tab: TabWithProperties, index: number) => TeactNode;
}; };
const TabList = ({ const TabList = ({
@ -38,11 +50,18 @@ const TabList = ({
centered, centered,
stretched, stretched,
itemAlignment, itemAlignment,
withFadeMask,
fadeMaskClassName,
renderExtra,
onSwitchTab, onSwitchTab,
}: OwnProps) => { }: OwnProps) => {
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const clipPathContainerRef = useRef<HTMLDivElement>(); const clipPathContainerRef = useRef<HTMLDivElement>();
const [clipPath, setClipPath] = useState<string>(''); const [clipPath, setClipPath] = useState<string>('');
const [isMenuOpen, openMenu, closeMenu] = useFlag();
const [menuAnchor, setMenuAnchor] = useState<IAnchorPosition | undefined>();
const [menuTabIndex, setMenuTabIndex] = useState<number | undefined>();
const menuTargetRef = useRef<HTMLElement>();
useHorizontalScroll(containerRef, !tabs.length, true); useHorizontalScroll(containerRef, !tabs.length, true);
@ -68,12 +87,42 @@ const TabList = ({
useResizeObserver(clipPathContainerRef, updateClipPath); useResizeObserver(clipPathContainerRef, updateClipPath);
useScrollToActiveTab(containerRef, activeTab);
const handleTabClick = useLastCallback((index: number) => { const handleTabClick = useLastCallback((index: number) => {
onSwitchTab(index); onSwitchTab(index);
}); });
const handleContextMenu = useLastCallback((index: number, e: React.MouseEvent) => {
const actions = tabs[index]?.contextActions;
if (!actions?.length) return;
e.preventDefault();
menuTargetRef.current = e.currentTarget as HTMLElement;
setMenuTabIndex(index);
setMenuAnchor({ x: e.clientX, y: e.clientY });
openMenu();
});
const handleMenuClose = useLastCallback(() => {
closeMenu();
});
const handleMenuHide = useLastCallback(() => {
setMenuAnchor(undefined);
setMenuTabIndex(undefined);
});
const getTriggerElement = useLastCallback(() => menuTargetRef.current);
const getRootElement = useLastCallback(() => containerRef.current);
const getMenuElement = useLastCallback(
() => containerRef.current?.querySelector<HTMLElement>('.TabList-context-menu .bubble'),
);
const getLayout = useLastCallback(() => ({ withPortal: true }));
if (!tabs.length) return undefined; if (!tabs.length) return undefined;
const hasContextActions = tabs.some((tab) => tab.contextActions?.length);
const renderTab = (tab: TabWithProperties, index: number) => { const renderTab = (tab: TabWithProperties, index: number) => {
const stringEmoticon = typeof tab.emoticon === 'string' ? tab.emoticon : undefined; const stringEmoticon = typeof tab.emoticon === 'string' ? tab.emoticon : undefined;
const customEmoji = typeof tab.emoticon === 'object' ? tab.emoticon : undefined; const customEmoji = typeof tab.emoticon === 'object' ? tab.emoticon : undefined;
@ -88,6 +137,7 @@ const TabList = ({
stretched && styles.stretched, stretched && styles.stretched,
)} )}
onClick={() => handleTabClick(index)} onClick={() => handleTabClick(index)}
onContextMenu={hasContextActions ? (e) => handleContextMenu(index, e) : undefined}
> >
{stringEmoticon && <span className={styles.tabEmoji}>{stringEmoticon}</span>} {stringEmoticon && <span className={styles.tabEmoji}>{stringEmoticon}</span>}
{customEmoji && ( {customEmoji && (
@ -99,17 +149,22 @@ const TabList = ({
/> />
)} )}
{tab.icon && <Icon name={tab.icon} className={styles.tabIcon} />} {tab.icon && <Icon name={tab.icon} className={styles.tabIcon} />}
{tab.title} {typeof tab.title === 'string' ? renderText(tab.title) : tab.title}
{renderExtra?.(tab, index)}
{tab.isBlocked && <Icon name="lock-badge" className={styles.lockIcon} />} {tab.isBlocked && <Icon name="lock-badge" className={styles.lockIcon} />}
</div> </div>
); );
}; };
return ( const contextActions = menuTabIndex !== undefined ? tabs[menuTabIndex]?.contextActions : undefined;
const tabListElement = (
<div <div
ref={containerRef} ref={containerRef}
className={buildClassName( className={buildClassName(
'TabList',
styles.container, styles.container,
withFadeMask && styles.withFadeMask,
centered && styles.centered, centered && styles.centered,
itemAlignment === 'vertical' && styles.vertical, itemAlignment === 'vertical' && styles.vertical,
className, className,
@ -131,6 +186,56 @@ const TabList = ({
</div> </div>
</div> </div>
); );
const menuElement = contextActions && menuAnchor !== undefined && (
<Menu
isOpen={isMenuOpen}
anchor={menuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="TabList-context-menu"
autoClose
onClose={handleMenuClose}
onCloseAnimationEnd={handleMenuHide}
withPortal
>
{contextActions.map((action: MenuItemContextAction) => (
('isSeparator' in action) ? (
<MenuSeparator key={action.key || `separator-${contextActions.indexOf(action)}`} />
) : (
<MenuItem
key={action.title}
icon={action.icon}
destructive={action.destructive}
disabled={!action.handler}
onClick={action.handler}
>
{renderText(action.title)}
</MenuItem>
)
))}
</Menu>
);
if (!withFadeMask) {
return (
<>
{tabListElement}
{menuElement}
</>
);
}
return (
<>
<div className={buildClassName(styles.fadeMaskWrapper, fadeMaskClassName)}>
{tabListElement}
</div>
{menuElement}
</>
);
}; };
export default memo(TabList); export default memo(TabList);

View File

@ -0,0 +1,30 @@
import type { ElementRef } from '../lib/teact/teact';
import { useEffect } from '../lib/teact/teact';
import animateHorizontalScroll from '../util/animateHorizontalScroll';
const TAB_SCROLL_THRESHOLD_PX = 16;
const SCROLL_DURATION = 300;
export default function useScrollToActiveTab(
containerRef: ElementRef<HTMLDivElement>,
activeTab: number,
) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const { scrollWidth, offsetWidth, scrollLeft } = container;
if (scrollWidth <= offsetWidth) return;
const activeTabElement = container.childNodes[activeTab] as HTMLElement | undefined;
if (!activeTabElement) return;
const { offsetLeft, offsetWidth: tabWidth } = activeTabElement;
const newLeft = offsetLeft - (offsetWidth / 2) + (tabWidth / 2);
if (Math.abs(newLeft - scrollLeft) < TAB_SCROLL_THRESHOLD_PX) return;
animateHorizontalScroll(container, newLeft, SCROLL_DURATION);
}, [activeTab, containerRef]);
}

View File

@ -176,8 +176,10 @@
width: 100%; width: 100%;
padding: 0.5625rem; padding: 0.5625rem;
border-radius: var(--border-radius-island);
background-color: var(--color-background); background-color: var(--color-background);
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
transition: transform var(--chat-transform-transition); transition: transform var(--chat-transform-transition);
@ -195,6 +197,11 @@
background-color: inherit; background-color: inherit;
} }
:global(html.theme-dark) & {
border: 1px solid var(--color-borders);
box-shadow: none;
}
} }
@mixin side-panel-section { @mixin side-panel-section {

View File

@ -232,7 +232,7 @@
--border-radius-modal: 2rem; --border-radius-modal: 2rem;
--border-radius-toast: 1rem; --border-radius-toast: 1rem;
--border-radius-island: 1.5rem; --border-radius-island: 1.5rem;
--border-radius-default: 0.75rem; --border-radius-default: 1rem;
--border-radius-default-small: 0.625rem; --border-radius-default-small: 0.625rem;
--border-radius-default-tiny: 0.375rem; --border-radius-default-tiny: 0.375rem;
--border-radius-messages: 0.9375rem; --border-radius-messages: 0.9375rem;