Story ribbon animation (#3798)

This commit is contained in:
Alexander Zinchuk 2023-09-25 18:53:21 +02:00
parent b090b80863
commit e8c4b8f713
17 changed files with 477 additions and 37 deletions

View File

@ -24,6 +24,7 @@
width: 100%;
height: 100%;
display: flex;
z-index: 1;
align-items: center;
justify-content: center;
background-image: linear-gradient(var(--color-white) -125%, var(--color-user));
@ -168,32 +169,49 @@
left: -0.25rem;
top: -0.25rem;
border-radius: 50%;
padding: 0.125rem;
background: var(--color-borders-read-story);
mask: linear-gradient(to bottom, #fff 0%, #fff 100%) content-box, linear-gradient(to bottom, #fff 0%, #fff 100%);
mask-composite: exclude;
box-shadow: none;
}
&::after {
content: "";
position: absolute;
width: 3.25rem;
height: 3.25rem;
left: -0.125rem;
top: -0.125rem;
border-radius: 50%;
z-index: 0;
background: var(--color-background);
}
&.size-tiny {
width: 2rem;
height: 2rem;
width: 1.75rem;
height: 1.75rem;
&::before {
width: 2.25rem;
height: 2.25rem;
}
&::after {
width: 2rem;
height: 2rem;
}
}
&.size-medium {
width: 2.75rem;
height: 2.75rem;
width: 2.5rem;
height: 2.5rem;
&::before {
width: 3rem;
height: 3rem;
}
&::after {
width: 2.75rem;
height: 2.75rem;
}
}
&.online::after {

View File

@ -244,6 +244,7 @@ const Avatar: FC<OwnProps> = ({
ref={ref}
className={fullClassName}
id={peer?.id && withStory ? getUserStoryHtmlId(peer.id) : undefined}
data-peer-id={peer?.id}
data-test-sender-id={IS_TEST ? peer?.id : undefined}
aria-label={typeof content === 'string' ? author : undefined}
onClick={handleClick}

View File

@ -1,6 +1,7 @@
.ArchivedChats {
height: 100%;
overflow: hidden;
background: var(--color-background);
.left-header {
position: relative;

View File

@ -6,7 +6,9 @@ import type { GlobalState } from '../../global/types';
import type { FolderEditDispatch } from '../../hooks/reducers/useFoldersReducer';
import type { LeftColumnContent, SettingsScreens } from '../../types';
import { ANIMATION_END_DELAY } from '../../config';
import buildClassName from '../../util/buildClassName';
import { ANIMATION_DURATION } from '../story/helpers/ribbonAnimation';
import useForumPanelRender from '../../hooks/useForumPanelRender';
import useHistoryBack from '../../hooks/useHistoryBack';
@ -80,7 +82,7 @@ const ArchivedChats: FC<OwnProps> = ({
shouldRender: shouldRenderStoryRibbon,
transitionClassNames: storyRibbonClassNames,
isClosing: isStoryRibbonClosing,
} = useShowTransition(isStoryRibbonShown, undefined, undefined, '');
} = useShowTransition(isStoryRibbonShown, undefined, undefined, '', false, ANIMATION_DURATION + ANIMATION_END_DELAY);
return (
<div className="ArchivedChats">

View File

@ -4,6 +4,7 @@
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-background);
> .Transition {
flex: 1;
@ -47,6 +48,7 @@
padding-left: 0.625rem;
padding-right: 0.625rem;
/* stylelint-disable-next-line */
> span {
padding-left: 0.5rem;
padding-right: 0.5rem;

View File

@ -1,19 +1,21 @@
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import type { ApiDimensions, ApiMessage } from '../../../api/types';
import { MediaViewerOrigin } from '../../../types';
import { ANIMATION_END_DELAY, MESSAGE_CONTENT_SELECTOR } from '../../../config';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getMessageHtmlId } from '../../../global/helpers';
import { isElementInViewport } from '../../../util/isElementInViewport';
import stopEvent from '../../../util/stopEvent';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import windowSize from '../../../util/windowSize';
import {
calculateDimensions,
getMediaViewerAvailableDimensions,
MEDIA_VIEWER_MEDIA_QUERY,
REM,
} from '../../common/helpers/mediaDimensions';
import windowSize from '../../../util/windowSize';
import stopEvent from '../../../util/stopEvent';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import { getMessageHtmlId } from '../../../global/helpers';
import { isElementInViewport } from '../../../util/isElementInViewport';
import { applyStyles } from '../../../util/animation';
const ANIMATION_DURATION = 200;
@ -283,10 +285,6 @@ function getTopOffset(hasFooter: boolean) {
return topOffsetRem * REM;
}
function applyStyles(element: HTMLElement, styles: Record<string, string>) {
Object.assign(element.style, styles);
}
function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
let containerSelector;
let mediaSelector;

View File

@ -4,7 +4,6 @@
column-gap: 0.625rem;
padding: 0.25rem 0.5rem 0.5rem 1rem;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
max-height: 5.5rem;
position: relative;
@ -37,6 +36,10 @@
&:focus {
outline: none;
}
&:global(.animating) {
opacity: 0;
}
}
.name {
@ -44,6 +47,7 @@
overflow: hidden;
text-overflow: ellipsis;
unicode-bidi: plaintext;
white-space: nowrap;
max-width: 110%;
&_hasUnreadStory {

View File

@ -44,6 +44,7 @@ function StoryRibbon({
return (
<div
ref={ref}
id="StoryRibbon"
className={fullClassName}
dir={lang.isRtl ? 'rtl' : undefined}
>

View File

@ -95,6 +95,7 @@ function StoryRibbonButton({ user, isArchived }: OwnProps) {
<div
ref={ref}
role="button"
data-peer-id={user.id}
tabIndex={0}
className={styles.user}
onMouseDown={handleMouseDown}

View File

@ -1,7 +1,10 @@
/* stylelint-disable-next-line */
@value name from "./StoryRibbon.module.scss";
.root {
position: absolute;
top: 50%;
right: 0.125rem;
right: 0.25rem;
transform: translateY(-50%);
padding: 0;
margin: 0;
@ -19,17 +22,48 @@
}
.avatar {
border: 0.125rem solid var(--color-background);
z-index: 1;
&::before {
z-index: -2;
&:not(:first-child):before {
mask-image: linear-gradient(90deg, #fff 75%, transparent 0);
mask-composite: exclude;
}
&:global(.has-unread-story)::before {
z-index: -1;
&:global(.animating) {
opacity: 0;
}
}
.avatarHidden {
display: none;
}
.avatar + .avatar {
margin-inline-end: -1.125rem;
margin-inline-end: -0.875rem;
}
.ghost {
position: absolute;
transform-origin: top left;
--transform-transition: transform 250ms ease;
--opacity-transition: opacity 250ms ease;
transition: var(--transform-transition), var(--opacity-transition);
}
.ghostAnimateName {
:global(.name) {
transition: var(--opacity-transition);
opacity: 0;
}
}
.ghostRevealName {
:global(.name) {
opacity: 1;
}
}
.ghostLast:before {
mask: none !important;
}

View File

@ -1,12 +1,14 @@
import React, { memo, useMemo } from '../../lib/teact/teact';
import React, { memo, useEffect, useMemo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiUser } from '../../api/types';
import { PREVIEW_AVATAR_COUNT } from '../../config';
import { selectTabState } from '../../global/selectors';
import { ANIMATION_END_DELAY, PREVIEW_AVATAR_COUNT } from '../../config';
import { selectPerformanceSettingsValue, selectTabState } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { animateClosing, animateOpening, ANIMATION_DURATION } from './helpers/ribbonAnimation';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import useLang from '../../hooks/useLang';
import useShowTransition from '../../hooks/useShowTransition';
import useStoryPreloader from './hooks/useStoryPreloader';
@ -24,6 +26,7 @@ interface StateProps {
currentUserId: string;
orderedUserIds: string[];
isShown: boolean;
withAnimation?: boolean;
usersById: Record<string, ApiUser>;
}
@ -36,6 +39,7 @@ function StoryToggler({
canShow,
isShown,
isArchived,
withAnimation,
}: OwnProps & StateProps) {
const { toggleStoryRibbon } = getActions();
@ -58,8 +62,20 @@ function StoryToggler({
}, [orderedUserIds]);
useStoryPreloader(preloadUserIds);
const isVisible = canShow && isShown;
// For some reason, setting 'slow' here also fixes scroll freezes on iOS when collapsing Story Ribbon
const { shouldRender, transitionClassNames } = useShowTransition(canShow && isShown, undefined, undefined, 'slow');
const { shouldRender, transitionClassNames } = useShowTransition(isVisible, undefined, undefined, 'slow');
useEffect(() => {
if (!withAnimation) return;
if (isVisible) {
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
animateClosing(isArchived);
} else {
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
animateOpening(isArchived);
}
}, [isArchived, isVisible, withAnimation]);
if (!shouldRender) {
return undefined;
@ -68,6 +84,7 @@ function StoryToggler({
return (
<button
type="button"
id="StoryToggler"
className={buildClassName(styles.root, transitionClassNames)}
aria-label={lang('Chat.Context.Peer.OpenStory')}
onClick={() => toggleStoryRibbon({ isShown: true, isArchived })}
@ -89,11 +106,13 @@ function StoryToggler({
export default memo(withGlobal<OwnProps>((global, { isArchived }): StateProps => {
const { orderedUserIds: { archived, active } } = global.stories;
const { storyViewer: { isRibbonShown, isArchivedRibbonShown } } = selectTabState(global);
const withAnimation = selectPerformanceSettingsValue(global, 'storyRibbonAnimations');
return {
currentUserId: global.currentUserId!,
orderedUserIds: isArchived ? archived : active,
isShown: isArchived ? !isArchivedRibbonShown : !isRibbonShown,
withAnimation,
usersById: global.users.byId,
};
})(StoryToggler));

View File

@ -22,7 +22,7 @@
}
&:global(.opacity-transition) {
transition: opacity 200ms;
transition: opacity 250ms;
}
:global(.text-entity-link) {

View File

@ -5,6 +5,7 @@ import { ANIMATION_END_DELAY } from '../../../config';
import fastBlur from '../../../lib/fastBlur';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getUserStoryHtmlId } from '../../../global/helpers';
import { applyStyles } from '../../../util/animation';
import stopEvent from '../../../util/stopEvent';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/windowEnvironment';
import windowSize from '../../../util/windowSize';
@ -231,7 +232,3 @@ function getNodes(origin: StoryViewerOrigin, userId: string) {
mediaEl: mediaEls?.[0],
};
}
function applyStyles(element: HTMLElement, css: Record<string, string>) {
Object.assign(element.style, css);
}

View File

@ -0,0 +1,354 @@
import { ANIMATION_END_DELAY } from '../../../config';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { applyStyles } from '../../../util/animation';
import stopEvent from '../../../util/stopEvent';
import { REM } from '../../common/helpers/mediaDimensions';
import ribbonStyles from '../StoryRibbon.module.scss';
import togglerStyles from '../StoryToggler.module.scss';
export const ANIMATION_DURATION = 250;
const RIBBON_OFFSET = 0.25 * REM;
const RIBBON_Z_INDEX = 11;
const STROKE_OFFSET = 0.1875 * REM;
const CANVAS_OFFSET = 0.1 * REM;
const callbacks: Set<NoneToVoidFunction> = new Set();
export function animateOpening(isArchived?: boolean) {
cancelDelayedCallbacks();
const {
container, toggler, leftMainHeader, ribbonUsers, toggleAvatars,
} = getHTMLElements(isArchived);
if (!toggler || !toggleAvatars || !ribbonUsers || !container || !leftMainHeader) {
return;
}
const { bottom: headerBottom, right: headerRight } = leftMainHeader.getBoundingClientRect();
const toTop = headerBottom + RIBBON_OFFSET;
// Toggle avatars are in the reverse order
const lastToggleAvatar = toggleAvatars[0];
const firstToggleAvatar = toggleAvatars[toggleAvatars.length - 1];
const lastId = getUserId(lastToggleAvatar);
Array.from(ribbonUsers).reverse().forEach((user, index, { length }) => {
const id = getUserId(user);
if (!id) return;
const isLast = id === lastId;
let toggleAvatar = selectByUserId(toggler, id);
let zIndex = RIBBON_Z_INDEX + index + 1;
if (!toggleAvatar) {
const isSelf = index === length - 1;
// Self story should appear from the first toggle avatar
toggleAvatar = isSelf ? firstToggleAvatar : lastToggleAvatar;
zIndex = RIBBON_Z_INDEX;
}
if (!toggleAvatar) return;
let {
// eslint-disable-next-line prefer-const
top: fromTop,
left: fromLeft,
width: fromWidth,
} = toggleAvatar.getBoundingClientRect();
const {
left: toLeft,
width: toWidth,
} = user.getBoundingClientRect();
if (toLeft > headerRight) {
return;
}
fromLeft -= STROKE_OFFSET;
fromWidth += 2 * STROKE_OFFSET;
const fromTranslateX = fromLeft - toLeft;
const fromTranslateY = fromTop - toTop;
const fromScale = fromWidth / toWidth;
fromTop -= STROKE_OFFSET;
const toTranslateX = toLeft - fromLeft;
const toTranslateY = toTop - fromTop - CANVAS_OFFSET;
const toScale = toWidth / fromWidth;
requestMutation(() => {
if (!toggleAvatar) return;
const ghost = createGhost(user);
let ghost2: HTMLElement | undefined;
// If this is a toogle avatar we create a second ghost and do crossfade animation
if (zIndex > RIBBON_Z_INDEX) {
ghost2 = createGhost(toggleAvatar!);
if (isLast) {
ghost2.classList.add(togglerStyles.ghostLast);
}
} else {
// Else we animate only name
ghost.classList.add(togglerStyles.ghostAnimateName);
}
applyStyles(ghost, {
top: `${toTop}px`,
left: `${toLeft}px`,
zIndex: `${zIndex}`,
opacity: ghost2 ? '0' : '',
transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScale})`,
});
if (ghost2) {
applyStyles(ghost2, {
top: `${fromTop}px`,
left: `${fromLeft}px`,
zIndex: `${zIndex}`,
});
}
container.appendChild(ghost);
if (ghost2) {
container.appendChild(ghost2);
}
toggleAvatar.classList.add('animating');
user.classList.add('animating');
requestMutation(() => {
applyStyles(ghost, {
opacity: '',
transform: '',
});
if (ghost2) {
applyStyles(ghost2, {
opacity: '0',
transform: `translate3d(${toTranslateX}px, ${toTranslateY}px, 0) scale(${toScale})`,
});
} else {
ghost.classList.add(togglerStyles.ghostRevealName);
}
const cb = createDelayedCallback(() => {
requestMutation(() => {
if (container.contains(ghost)) {
container.removeChild(ghost);
}
if (ghost2 && container.contains(ghost2)) {
container.removeChild(ghost2);
}
toggleAvatar?.classList.remove('animating');
user.classList.remove('animating');
});
}, ANIMATION_DURATION + ANIMATION_END_DELAY);
callbacks.add(cb);
});
});
});
}
export function animateClosing(isArchived?: boolean) {
cancelDelayedCallbacks();
const {
container,
toggler,
toggleAvatars,
ribbonUsers,
leftMainHeader,
} = getHTMLElements(isArchived);
if (!toggler || !toggleAvatars || !ribbonUsers || !container || !leftMainHeader) {
return;
}
const { right: headerRight } = leftMainHeader.getBoundingClientRect();
// Toggle avatars are in the reverse order
const lastToggleAvatar = toggleAvatars[0];
const firstToggleAvatar = toggleAvatars[toggleAvatars.length - 1];
const lastId = getUserId(lastToggleAvatar);
Array.from(ribbonUsers).reverse().forEach((user, index, { length }) => {
const id = getUserId(user);
if (!id) return;
const isLast = id === lastId;
let toggleAvatar = selectByUserId(toggler, id);
let zIndex = RIBBON_Z_INDEX + index + 1;
if (!toggleAvatar) {
const isSelf = index === length - 1;
// Self story should appear from the first toggle avatar
toggleAvatar = isSelf ? firstToggleAvatar : lastToggleAvatar;
zIndex = RIBBON_Z_INDEX;
}
if (!toggleAvatar) return;
const {
top: fromTop,
left: fromLeft,
width: fromWidth,
} = user.getBoundingClientRect();
let {
left: toLeft,
width: toWidth,
top: toTop,
} = toggleAvatar.getBoundingClientRect();
if (fromLeft > headerRight) {
return;
}
toLeft -= STROKE_OFFSET;
toWidth += 2 * STROKE_OFFSET;
const toTranslateX = toLeft - fromLeft;
const toTranslateY = toTop - fromTop - CANVAS_OFFSET;
const toScale = toWidth / fromWidth;
toTop -= STROKE_OFFSET;
const fromTranslateX = fromLeft - toLeft;
const fromTranslateY = fromTop - toTop;
const fromScale = fromWidth / toWidth;
requestMutation(() => {
const ghost = createGhost(user);
let ghost2: HTMLElement | undefined;
if (zIndex > RIBBON_Z_INDEX) {
ghost2 = createGhost(toggleAvatar!);
if (isLast) {
ghost2.classList.add(togglerStyles.ghostLast);
}
} else {
ghost.classList.add(togglerStyles.ghostAnimateName, togglerStyles.ghostRevealName);
}
applyStyles(ghost, {
top: `${fromTop}px`,
left: `${fromLeft}px`,
width: `${fromWidth}px`,
zIndex: `${zIndex}`,
});
if (ghost2) {
applyStyles(ghost2, {
top: `${toTop}px`,
left: `${toLeft}px`,
zIndex: `${zIndex}`,
opacity: '0',
transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScale})`,
});
}
user.classList.add('animating');
toggleAvatar!.classList.add('animating');
container.appendChild(ghost);
if (ghost2) {
container.appendChild(ghost2);
}
requestMutation(() => {
applyStyles(ghost, {
opacity: ghost2 ? '0' : '',
transform: `translate3d(${toTranslateX}px, ${toTranslateY}px, 0) scale(${toScale})`,
});
if (ghost2) {
applyStyles(ghost2!, {
opacity: '',
transform: '',
});
} else {
ghost.classList.remove(togglerStyles.ghostRevealName);
}
const cb = createDelayedCallback(() => {
requestMutation(() => {
if (container.contains(ghost)) {
container.removeChild(ghost);
}
if (ghost2 && container.contains(ghost2)) {
container.removeChild(ghost2);
}
user.classList.remove('animating');
toggleAvatar!.classList.remove('animating');
});
}, ANIMATION_DURATION + ANIMATION_END_DELAY);
callbacks.add(cb);
});
});
});
}
function getHTMLElements(isArchived?: boolean) {
let container = document.getElementById('LeftColumn');
if (container && isArchived) {
container = container.querySelector<HTMLElement>('.ArchivedChats');
}
if (!container) return {};
const toggler = container.querySelector<HTMLElement>('#StoryToggler');
const ribbon = container.querySelector<HTMLElement>('#StoryRibbon');
const leftMainHeader = container.querySelector<HTMLElement>('.left-header');
const ribbonUsers = ribbon?.querySelectorAll<HTMLElement>(`.${ribbonStyles.user}`);
const toggleAvatars = toggler?.querySelectorAll<HTMLElement>('.Avatar');
return {
container,
toggler,
leftMainHeader,
ribbonUsers,
toggleAvatars,
};
}
function createGhost(sourceEl: HTMLElement) {
const ghost = sourceEl.cloneNode(true) as HTMLElement;
ghost.classList.add(togglerStyles.ghost);
// Avoid source animating class being copied to the ghost
ghost.classList.remove('animating');
ghost.draggable = false;
ghost.oncontextmenu = stopEvent;
const sourceCanvas = sourceEl.querySelector('canvas');
if (sourceCanvas) {
const canvas = ghost.querySelector('canvas');
canvas?.getContext('2d')?.drawImage(sourceCanvas, 0, 0);
}
return ghost;
}
function getUserId(el: HTMLElement) {
return el.getAttribute('data-peer-id');
}
function selectByUserId(el: HTMLElement, id: string) {
return el.querySelector<HTMLElement>(`[data-peer-id="${id}"]`);
}
function createDelayedCallback(callback: NoneToVoidFunction, ms: number) {
const timeout = setTimeout(callback, ms);
return () => {
clearTimeout(timeout);
callback();
};
}
function cancelDelayedCallbacks() {
callbacks.forEach((cb) => cb());
callbacks.clear();
}

View File

@ -28,6 +28,7 @@ export const INITIAL_PERFORMANCE_STATE_MAX: PerformanceType = {
reactionEffects: true,
rightColumnAnimations: true,
stickerEffects: true,
storyRibbonAnimations: true,
};
export const INITIAL_PERFORMANCE_STATE_MID: PerformanceType = {
@ -44,6 +45,7 @@ export const INITIAL_PERFORMANCE_STATE_MID: PerformanceType = {
reactionEffects: true,
rightColumnAnimations: false,
stickerEffects: false,
storyRibbonAnimations: false,
};
export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = {
@ -60,6 +62,7 @@ export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = {
reactionEffects: false,
rightColumnAnimations: false,
stickerEffects: false,
storyRibbonAnimations: false,
};
export const INITIAL_GLOBAL_STATE: GlobalState = {

View File

@ -35,6 +35,7 @@ export type PerformanceTypeKey = (
'pageTransitions' | 'messageSendingAnimations' | 'mediaViewerAnimations'
| 'messageComposerAnimations' | 'contextMenuAnimations' | 'contextMenuBlur' | 'rightColumnAnimations'
| 'animatedEmoji' | 'loopAnimatedStickers' | 'reactionEffects' | 'stickerEffects' | 'autoplayGifs' | 'autoplayVideos'
| 'storyRibbonAnimations'
);
export type PerformanceType = {
[key in PerformanceTypeKey]: boolean;

View File

@ -110,3 +110,7 @@ export function animateNumber<T extends number | number[]>({
if (onEnd) onEnd();
};
}
export function applyStyles(element: HTMLElement, css: Record<string, string>) {
Object.assign(element.style, css);
}