Profile: Optimize accordion transitions (#6488)

This commit is contained in:
zubiden 2025-11-22 12:54:12 +01:00 committed by Alexander Zinchuk
parent 6054536ee9
commit 9738c34242
7 changed files with 267 additions and 100 deletions

View File

@ -1,5 +1,9 @@
@use '../../../styles/mixins';
.root {
cursor: pointer;
@include mixins.with-vt-type('profileBusinessHours');
}
.top {
@ -32,6 +36,8 @@
margin-inline-end: 0.375rem;
font-size: 1.25rem;
color: var(--color-text-secondary);
@include mixins.with-vt-type('profileBusinessHours');
}
.offset-trigger {
@ -54,13 +60,13 @@
&:hover {
background-color: var(--color-primary-opacity);
}
@include mixins.with-vt-type('profileBusinessHours');
}
.transition {
height: 0;
margin-bottom: 0.5rem;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.timetable {
@ -91,3 +97,42 @@
.current-day {
color: var(--color-primary);
}
@include mixins.on-active-vt('profileBusinessHours') {
&::view-transition-image-pair(.businessHours) {
overflow: hidden;
}
&::view-transition-old(.businessHours),
&::view-transition-new(.businessHours) {
animation-name: none;
}
}
@include mixins.on-active-vt('profileBusinessHoursExpand') {
&::view-transition-old(.expandArrow) {
animation-name: vt-expand-icon-spin;
}
&::view-transition-new(.expandArrow) {
display: none;
}
&::view-transition-old(.businessHours) {
display: none;
}
}
@include mixins.on-active-vt('profileBusinessHoursCollapse') {
&::view-transition-old(.expandArrow) {
animation-name: vt-collapse-icon-spin;
}
&::view-transition-new(.expandArrow) {
display: none;
}
&::view-transition-new(.businessHours) {
display: none;
}
}

View File

@ -1,11 +1,14 @@
import type React from '../../../lib/teact/teact';
import {
memo, useEffect, useMemo, useRef,
memo, useMemo,
} from '../../../lib/teact/teact';
import type { ApiBusinessWorkHours } from '../../../api/types';
import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom';
import {
VTT_PROFILE_BUSINESS_HOURS,
VTT_PROFILE_BUSINESS_HOURS_COLLAPSE,
VTT_PROFILE_BUSINESS_HOURS_EXPAND,
} from '../../../util/animations/viewTransitionTypes';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { formatTime, formatWeekday } from '../../../util/dates/dateFormat';
@ -13,6 +16,8 @@ import {
getUtcOffset, getWeekStart, shiftTimeRanges, splitDays,
} from '../../../util/dates/workHours';
import { useViewTransition } from '../../../hooks/animations/useViewTransition';
import { useVtn } from '../../../hooks/animations/useVtn';
import useSelectorSignal from '../../../hooks/data/useSelectorSignal';
import useInterval from '../../../hooks/schedulers/useInterval';
import useDerivedState from '../../../hooks/useDerivedState';
@ -22,7 +27,6 @@ import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import ListItem from '../../ui/ListItem';
import Transition, { ACTIVE_SLIDE_CLASS_NAME, TO_SLIDE_CLASS_NAME } from '../../ui/Transition';
import Icon from '../icons/Icon';
import styles from './BusinessHours.module.scss';
@ -31,17 +35,21 @@ const DAYS = Array.from({ length: 7 }, (_, i) => i);
type OwnProps = {
businessHours: ApiBusinessWorkHours;
className?: string;
};
const BusinessHours = ({
businessHours,
className,
}: OwnProps) => {
const transitionRef = useRef<HTMLDivElement>();
const [isExpanded, expand, collapse] = useFlag(false);
const [isMyTime, showInMyTime, showInLocalTime] = useFlag(false);
const lang = useOldLang();
const oldLang = useOldLang();
const forceUpdate = useForceUpdate();
const { startViewTransition } = useViewTransition();
const { createVtnStyle } = useVtn();
useInterval(forceUpdate, 60 * 1000);
const timezoneSignal = useSelectorSignal((global) => global.timezones?.byId);
@ -62,20 +70,20 @@ const BusinessHours = ({
DAYS.forEach((day) => {
const segments = days[day];
if (!segments) {
result[day] = [lang('BusinessHoursDayClosed')];
result[day] = [oldLang('BusinessHoursDayClosed')];
return;
}
result[day] = segments.map(({ startMinute, endMinute }) => {
if (endMinute - startMinute === 24 * 60) return lang('BusinessHoursDayFullOpened');
const start = formatTime(lang, weekStart + startMinute * 60 * 1000);
const end = formatTime(lang, weekStart + endMinute * 60 * 1000);
if (endMinute - startMinute === 24 * 60) return oldLang('BusinessHoursDayFullOpened');
const start = formatTime(oldLang, weekStart + startMinute * 60 * 1000);
const end = formatTime(oldLang, weekStart + endMinute * 60 * 1000);
return `${start} ${end}`;
});
});
return result;
}, [businessHours.workHours, isMyTime, lang, timezoneMinuteDifference]);
}, [businessHours.workHours, isMyTime, oldLang, timezoneMinuteDifference]);
const isBusinessOpen = useMemo(() => {
const localTimeHours = shiftTimeRanges(businessHours.workHours, timezoneMinuteDifference);
@ -96,41 +104,25 @@ const BusinessHours = ({
const handleClick = useLastCallback(() => {
if (isExpanded) {
collapse();
startViewTransition(VTT_PROFILE_BUSINESS_HOURS_COLLAPSE, () => {
collapse();
});
} else {
expand();
startViewTransition(VTT_PROFILE_BUSINESS_HOURS_EXPAND, () => {
expand();
});
}
});
const handleTriggerOffset = useLastCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (isMyTime) {
showInLocalTime();
} else {
showInMyTime();
}
});
useEffect(() => {
if (!isExpanded) return;
const slide = document.querySelector<HTMLElement>(`.${ACTIVE_SLIDE_CLASS_NAME} > .${styles.timetable}`);
if (!slide) return;
const height = slide.offsetHeight;
requestMutation(() => {
transitionRef.current!.style.height = `${height}px`;
});
}, [isExpanded]);
const handleAnimationStart = useLastCallback(() => {
const slide = document.querySelector<HTMLElement>(`.${TO_SLIDE_CLASS_NAME} > .${styles.timetable}`)!;
requestMeasure(() => {
const height = slide.offsetHeight;
requestMutation(() => {
transitionRef.current!.style.height = `${height}px`;
});
startViewTransition(VTT_PROFILE_BUSINESS_HOURS, () => {
if (isMyTime) {
showInLocalTime();
} else {
showInMyTime();
}
});
});
@ -139,7 +131,8 @@ const BusinessHours = ({
icon="clock"
iconClassName={styles.icon}
multiline
className={styles.root}
className={buildClassName(styles.root, className)}
style={createVtnStyle('businessHours', true)}
isStatic={isExpanded}
ripple
narrow
@ -148,48 +141,43 @@ const BusinessHours = ({
>
<div className={styles.top}>
<div className={styles.left}>
<div>{lang('BusinessHoursProfile')}</div>
<div className={buildClassName(styles.status, isBusinessOpen && styles.statusOpen)}>
{isBusinessOpen ? lang('BusinessHoursProfileNowOpen') : lang('BusinessHoursProfileNowClosed')}
<div>{oldLang('BusinessHoursProfile')}</div>
<div
className={buildClassName(styles.status, isBusinessOpen && styles.statusOpen)}
>
{isBusinessOpen ? oldLang('BusinessHoursProfileNowOpen') : oldLang('BusinessHoursProfileNowClosed')}
</div>
</div>
<Icon className={styles.arrow} name={isExpanded ? 'up' : 'down'} />
<Icon className={styles.arrow} style={createVtnStyle('expandArrow', true)} name={isExpanded ? 'up' : 'down'} />
</div>
{isExpanded && (
<div className={styles.bottom}>
{Boolean(timezoneMinuteDifference) && (
<div
className={styles.offsetTrigger}
style={createVtnStyle('offsetTrigger')}
role="button"
tabIndex={0}
onMouseDown={!IS_TOUCH_ENV ? handleTriggerOffset : undefined}
onClick={IS_TOUCH_ENV ? handleTriggerOffset : undefined}
>
{lang(isMyTime ? 'BusinessHoursProfileSwitchMy' : 'BusinessHoursProfileSwitchLocal')}
{oldLang(isMyTime ? 'BusinessHoursProfileSwitchMy' : 'BusinessHoursProfileSwitchLocal')}
</div>
)}
<Transition
className={styles.transition}
ref={transitionRef}
name="fade"
activeKey={Number(isMyTime)}
onStart={handleAnimationStart}
>
<dl className={styles.timetable}>
{DAYS.map((day) => (
<>
<dt className={buildClassName(styles.weekday, day === currentDay && styles.currentDay)}>
{formatWeekday(lang, day === 6 ? 0 : day + 1)}
</dt>
<dd className={styles.schedule}>
{workHours[day].map((segment) => (
<div>{segment}</div>
))}
</dd>
</>
))}
</dl>
</Transition>
<dl className={styles.timetable}>
{DAYS.map((day) => (
<>
<dt className={buildClassName(styles.weekday, day === currentDay && styles.currentDay)}>
{formatWeekday(oldLang, day === 6 ? 0 : day + 1)}
</dt>
<dd className={styles.schedule}>
{workHours[day].map((segment) => (
<div>{segment}</div>
))}
</dd>
</>
))}
</dl>
</div>
)}
</ListItem>

View File

@ -9,6 +9,8 @@
border-radius: 0.25rem;
object-fit: cover;
@include mixins.with-vt-type('chatExtra');
}
.personalChannel {
@ -18,6 +20,8 @@
column-gap: 0.5rem;
margin-bottom: 0.5rem;
@include mixins.with-vt-type('chatExtra');
}
.personalChannelTitle {
@ -31,6 +35,11 @@
color: var(--color-text-secondary);
}
.phone, .description, .link, .miniapp, .notifications, .location, .note, .savedMessages, .botEmojiStatus,
.botLocation, .subscribers {
@include mixins.with-vt-type('chatExtra');
}
.botVerificationSection,
.sectionInfo {
font-size: 0.875rem;
@ -39,6 +48,8 @@
.botVerificationSection {
padding-inline: 1.25rem;
@include mixins.with-vt-type('chatExtra');
}
.botVerificationIcon {
@ -71,9 +82,15 @@
margin-bottom: 0.5rem;
}
.note {
@include mixins.with-vt-type('profileNote');
}
.noteSubtitle {
display: flex !important;
align-items: center;
@include mixins.with-vt-type('profileNote');
}
.noteListItemIcon {
@ -87,13 +104,9 @@
.noteText {
position: relative;
overflow: hidden;
display: inline-block;
width: 100%;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: max-height 0.3s ease;
&::after {
pointer-events: none;
@ -111,23 +124,61 @@
transition: opacity 0.3s ease;
}
@include mixins.with-vt-type('profileNote');
}
.noteTextCollapsed::after {
opacity: 1;
}
.noteCollapseIcon {
.noteExpandIcon {
margin-inline-start: 0.125rem;
font-size: 0.9375rem;
line-height: 0.9375rem;
transition: transform 0.3s ease;
}
.expandedIcon {
transform: rotate(-180deg);
@include mixins.with-vt-type('profileNote');
}
.clickable {
cursor: var(--custom-cursor, pointer);
}
@include mixins.on-active-vt('profileNote') {
&::view-transition-image-pair(.noteText) {
overflow: hidden;
}
&::view-transition-old(.noteText),
&::view-transition-new(.noteText) {
animation-name: none;
}
}
@include mixins.on-active-vt('profileNoteExpand') {
&::view-transition-old(.noteExpandIcon) {
animation-name: vt-expand-icon-spin;
}
&::view-transition-new(.noteExpandIcon) {
display: none;
}
&::view-transition-old(.noteText) {
display: none;
}
}
@include mixins.on-active-vt('profileNoteCollapse') {
&::view-transition-old(.noteExpandIcon) {
animation-name: vt-collapse-icon-spin;
}
&::view-transition-new(.noteExpandIcon) {
display: none;
}
&::view-transition-new(.noteText) {
display: none;
}
}

View File

@ -38,6 +38,7 @@ import {
selectUser,
selectUserFullInfo,
} from '../../../global/selectors';
import { VTT_PROFILE_NOTE_COLLAPSE, VTT_PROFILE_NOTE_EXPAND } from '../../../util/animations/viewTransitionTypes';
import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard';
import { formatPhoneNumberWithCode } from '../../../util/phoneNumber';
@ -48,6 +49,8 @@ import formatUsername from '../helpers/formatUsername';
import renderText from '../helpers/renderText';
import { renderTextWithEntities } from '../helpers/renderTextWithEntities';
import { useViewTransition } from '../../../hooks/animations/useViewTransition';
import { useVtn } from '../../../hooks/animations/useVtn';
import useCollapsibleLines from '../../../hooks/element/useCollapsibleLines';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import useLang from '../../../hooks/useLang';
@ -75,7 +78,6 @@ type OwnProps = {
isSavedDialog?: boolean;
isInSettings?: boolean;
className?: string;
style?: string;
};
type StateProps = {
@ -105,7 +107,7 @@ const DEFAULT_MAP_CONFIG = {
};
const BOT_VERIFICATION_ICON_SIZE = 16;
const MAX_LINES = 3;
const NOTE_MAX_LINES = 3;
const ChatExtra = ({
chatOrUserId,
@ -127,7 +129,6 @@ const ChatExtra = ({
botAppPermissions,
botVerification,
className,
style,
isInSettings,
canViewSubscribers,
}: OwnProps & StateProps) => {
@ -163,6 +164,9 @@ const ChatExtra = ({
const oldLang = useOldLang();
const lang = useLang();
const { startViewTransition } = useViewTransition();
const { createVtnStyle } = useVtn();
const noteTextRef = useRef<HTMLDivElement>();
const shouldRenderNote = Boolean(note);
@ -173,7 +177,7 @@ const ChatExtra = ({
setIsCollapsed: setIsNoteCollapsed,
} = useCollapsibleLines(
noteTextRef,
MAX_LINES,
NOTE_MAX_LINES,
undefined,
!shouldRenderNote,
);
@ -264,11 +268,16 @@ const ChatExtra = ({
const canExpandNote = isNoteCollapsible && isNoteCollapsed;
const handleExpandNote = useLastCallback(() => {
setIsNoteCollapsed(false);
startViewTransition(VTT_PROFILE_NOTE_EXPAND, () => {
setIsNoteCollapsed(false);
});
});
const handleToggleNote = useLastCallback(() => {
setIsNoteCollapsed((prev) => !prev);
const isCollapsed = isNoteCollapsed;
startViewTransition(isCollapsed ? VTT_PROFILE_NOTE_EXPAND : VTT_PROFILE_NOTE_COLLAPSE, () => {
setIsNoteCollapsed(() => !isCollapsed);
});
});
function copy(text: string, entity: string) {
@ -382,9 +391,9 @@ const ChatExtra = ({
}
return (
<div className={buildClassName('ChatExtra', className)} style={style}>
<div className={buildClassName('ChatExtra', className)} style={createVtnStyle('chatExtra')}>
{personalChannel && (
<div className={styles.personalChannel}>
<div className={styles.personalChannel} style={createVtnStyle('personalChannel')}>
<h3 className={styles.personalChannelTitle}>{oldLang('ProfileChannel')}</h3>
<span className={styles.personalChannelSubscribers}>
{oldLang('Subscribers', personalChannel.membersCount, 'i')}
@ -400,8 +409,15 @@ const ChatExtra = ({
</div>
)}
{Boolean(formattedNumber?.length) && (
<ListItem icon="phone" multiline narrow ripple onClick={handlePhoneClick}>
<ListItem
icon="phone"
className={styles.phone}
multiline
narrow
ripple
onClick={handlePhoneClick}
style={createVtnStyle('phone')}
>
<span className="title" dir={lang.isRtl ? 'rtl' : undefined}>{formattedNumber}</span>
<span className="subtitle">{oldLang('Phone')}</span>
</ListItem>
@ -410,10 +426,12 @@ const ChatExtra = ({
{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}>
{
@ -432,10 +450,11 @@ const ChatExtra = ({
<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>
@ -447,8 +466,10 @@ const ChatExtra = ({
{hasMainMiniApp && (
<ListItem
multiline
className={styles.miniapp}
isStatic
narrow
style={createVtnStyle('miniapp')}
>
<Button
className={styles.openAppButton}
@ -462,7 +483,14 @@ const ChatExtra = ({
</ListItem>
)}
{!isOwnProfile && !isInSettings && (
<ListItem icon={isMuted ? 'mute' : 'unmute'} narrow ripple onClick={handleToggleNotifications}>
<ListItem
icon={isMuted ? 'mute' : 'unmute'}
className={styles.notifications}
narrow
ripple
onClick={handleToggleNotifications}
style={createVtnStyle('notifications')}
>
<span>{lang('Notifications')}</span>
<Switcher
id="group-notifications"
@ -481,6 +509,8 @@ const ChatExtra = ({
ripple
multiline
narrow
className={styles.location}
style={createVtnStyle('location')}
rightElement={locationRightComponent}
onClick={handleClickLocation}
>
@ -496,6 +526,8 @@ const ChatExtra = ({
narrow
isStatic
allowSelection
className={styles.note}
style={createVtnStyle('note')}
>
<div
ref={noteTextRef}
@ -506,6 +538,7 @@ const ChatExtra = ({
styles.noteText,
isNoteCollapsed && styles.noteTextCollapsed,
)}
style={createVtnStyle('noteText', true)}
dir={lang.isRtl ? 'rtl' : undefined}
onClick={canExpandNote ? handleExpandNote : undefined}
>
@ -514,31 +547,45 @@ const ChatExtra = ({
entities: note.entities,
})}
</div>
<div className={buildClassName('subtitle', styles.noteSubtitle)}>
<div className={buildClassName('subtitle', styles.noteSubtitle)} style={createVtnStyle('noteSubtitle')}>
<span>{lang('UserNoteTitle')}</span>
<span className={styles.noteHint}>{lang('UserNoteHint')}</span>
{isNoteCollapsible && (
<Icon
className={buildClassName(
styles.noteCollapseIcon,
styles.noteExpandIcon,
styles.clickable,
!isNoteCollapsed && styles.expandedIcon,
)}
style={createVtnStyle('noteExpandIcon', true)}
onClick={handleToggleNote}
name="down"
name={isNoteCollapsed ? 'down' : 'up'}
/>
)}
</div>
</ListItem>
)}
{hasSavedMessages && !isOwnProfile && !isInSettings && (
<ListItem icon="saved-messages" narrow ripple onClick={handleOpenSavedDialog}>
<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" narrow ripple onClick={manageEmojiStatusChange}>
<ListItem
icon="user"
className={styles.botEmojiStatus}
narrow
ripple
onClick={manageEmojiStatusChange}
style={createVtnStyle('botEmojiStatus')}
>
<span>{oldLang('BotProfilePermissionEmojiStatus')}</span>
<Switcher
label={oldLang('BotProfilePermissionEmojiStatus')}
@ -548,7 +595,14 @@ const ChatExtra = ({
</ListItem>
)}
{botAppPermissions?.geolocation !== undefined && (
<ListItem icon="location" narrow ripple onClick={handleLocationPermissionChange}>
<ListItem
icon="location"
className={styles.botLocation}
narrow
ripple
onClick={handleLocationPermissionChange}
style={createVtnStyle('botLocation')}
>
<span>{oldLang('BotProfilePermissionLocation')}</span>
<Switcher
label={oldLang('BotProfilePermissionLocation')}
@ -558,13 +612,21 @@ const ChatExtra = ({
</ListItem>
)}
{canViewSubscribers && (
<ListItem icon="group" narrow multiline ripple onClick={handleOpenSubscribers}>
<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}>
<div className={styles.botVerificationSection} style={createVtnStyle('botVerification')}>
<CustomEmoji
className={styles.botVerificationIcon}
documentId={botVerification.iconId}

View File

@ -1116,7 +1116,6 @@ const Profile = ({
chatOrUserId={profileId}
isSavedDialog={isSavedDialog}
isOwnProfile={isOwnProfile}
style={createVtnStyle('chatExtra')}
/>
</div>
);

View File

@ -424,6 +424,18 @@ body:not(.is-ios) {
}
}
@keyframes vt-expand-icon-spin {
to {
transform: rotate(180deg);
}
}
@keyframes vt-collapse-icon-spin {
to {
transform: rotate(-180deg);
}
}
.component-theme-dark {
--color-background: rgb(33, 33, 33);
--color-background-compact-menu: rgb(33, 33, 33, 0.867);

View File

@ -21,3 +21,13 @@ export const VTT_RIGHT_PROFILE_EXPAND = VTT_RIGHT_PROFILE_AVATAR.with('profileEx
export const VTT_RIGHT_PROFILE_COLLAPSE = VTT_RIGHT_PROFILE_AVATAR.with('profileCollapse');
export const VTT_PROFILE_GIFTS = VTT_RIGHT_COLUMN.with('profileGifts');
export const VTT_CHAT_EXTRA = VTT_RIGHT_COLUMN.with('chatExtra');
export const VTT_PROFILE_BUSINESS_HOURS = VTT_CHAT_EXTRA.with('profileBusinessHours');
export const VTT_PROFILE_BUSINESS_HOURS_EXPAND = VTT_PROFILE_BUSINESS_HOURS.with('profileBusinessHoursExpand');
export const VTT_PROFILE_BUSINESS_HOURS_COLLAPSE = VTT_PROFILE_BUSINESS_HOURS.with('profileBusinessHoursCollapse');
export const VTT_PROFILE_NOTE = VTT_CHAT_EXTRA.with('profileNote');
export const VTT_PROFILE_NOTE_EXPAND = VTT_PROFILE_NOTE.with('profileNoteExpand');
export const VTT_PROFILE_NOTE_COLLAPSE = VTT_PROFILE_NOTE.with('profileNoteCollapse');