Refactor StoryProgressSegment to have better controlled animations

This commit is contained in:
Jamie Kyle 2024-08-13 15:19:34 -07:00 committed by GitHub
parent a973c27fd4
commit 74b90a5cdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 171 additions and 147 deletions

View File

@ -172,6 +172,13 @@ const rules = {
'`with` is disallowed in strict mode because it makes code impossible to predict and optimize.',
},
],
'react-hooks/exhaustive-deps': [
'error',
{
additionalHooks: '^(useSpring|useSprings)$',
},
],
};
const typescriptRules = {

View File

@ -0,0 +1,26 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoryProgressSegment {
background: $color-white-alpha-40;
border-radius: 2px;
height: 2px;
margin-block: 12px 0;
margin-inline: 1px;
overflow: hidden;
width: 100%;
}
.StoryProgressSegment__bar {
background: $color-white;
border-radius: 2px;
height: 100%;
&:dir(ltr) {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(-100%);
}
&:dir(rtl) {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(100%);
}
}

View File

@ -303,30 +303,6 @@
&__progress {
display: flex;
&--container {
background: $color-white-alpha-40;
border-radius: 2px;
height: 2px;
margin-block: 12px 0;
margin-inline: 1px;
overflow: hidden;
width: 100%;
}
&--bar {
background: $color-white;
border-radius: 2px;
height: 100%;
&:dir(ltr) {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(-100%);
}
&:dir(rtl) {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(100%);
}
}
}
&__animated-emojis {

View File

@ -162,6 +162,7 @@
@import './components/StoryImage.scss';
@import './components/StoryLinkPreview.scss';
@import './components/StoryListItem.scss';
@import './components/StoryProgressSegment.scss';
@import './components/StoryReplyQuote.scss';
@import './components/StoryViewer.scss';
@import './components/StoryViewsNRepliesModal.scss';

View File

@ -174,6 +174,7 @@ export function CallingRaisedHandsListButton({
}: CallingRaisedHandsListButtonPropsType): JSX.Element | null {
const [isVisible, setIsVisible] = React.useState(raisedHandsCount > 0);
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [opacitySpringProps, opacitySpringApi] = useSpring(
{
from: { opacity: 0 },
@ -182,6 +183,7 @@ export function CallingRaisedHandsListButton({
},
[]
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [scaleSpringProps, scaleSpringApi] = useSpring(
{
from: { scale: 0.9 },

View File

@ -322,6 +322,7 @@ export function Lightbox({
const thumbnailsMarginInlineStart =
0 - (selectedIndex * THUMBNAIL_FULL_WIDTH + THUMBNAIL_WIDTH / 2);
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [thumbnailsStyle, thumbnailsAnimation] = useSpring(
{
config: THUMBNAIL_SPRING_CONFIG,

View File

@ -27,6 +27,7 @@ export type ButtonProps = {
export const PlaybackButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
function ButtonInner(props, ref) {
const { mod, label, variant, onClick, context, visible = true } = props;
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [animProps] = useSpring(
{
config: SPRING_CONFIG,

View File

@ -31,6 +31,7 @@ export function PlaybackRateButton({
}: Props): JSX.Element {
const [isDown, setIsDown] = useState(false);
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [animProps] = useSpring(
{
config: SPRING_CONFIG,

View File

@ -124,7 +124,7 @@ export function StoryImage({
storyElement = (
<video
autoPlay
autoPlay={!isPaused}
className={getClassName('__image')}
controls={false}
key={attachment.url}

View File

@ -0,0 +1,65 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useEffect, useRef } from 'react';
import { animated, useSpring } from '@react-spring/web';
export type StoryProgressSegmentProps = Readonly<{
currentIndex: number;
duration: number | null;
index: number;
playing: boolean;
onFinish: () => void;
}>;
function isValidDuration(duration: number | null): boolean {
return duration != null && Number.isFinite(duration) && duration > 0;
}
export const StoryProgressSegment = memo(function StoryProgressSegment({
currentIndex,
duration,
index,
playing,
onFinish,
}: StoryProgressSegmentProps): JSX.Element {
const onFinishRef = useRef(onFinish);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
const [progressBarStyle] = useSpring(() => {
return {
// Override default value from `Globals` to ignore "Reduce Motion" setting.
// This animation is important for progressing through stories and is minor
// enough that it shouldn't cause issues for users with sensitivity to motion.
skipAnimation: false,
immediate: index !== currentIndex,
// Pause while we are waiting for a valid duration
pause: !playing || !isValidDuration(duration),
from: { x: index < currentIndex ? 1 : 0 },
to: { x: index <= currentIndex ? 1 : 0 },
config: { duration: duration ?? Infinity },
onRest: result => {
if (index === currentIndex && result.finished) {
onFinishRef.current();
}
},
};
}, [index, playing, currentIndex, duration]);
return (
<div
className="StoryProgressSegment"
aria-current={index === currentIndex ? 'step' : false}
>
<animated.div
className="StoryProgressSegment__bar"
style={{
transform: progressBarStyle.x.to(
value => `translateX(${value * 100 - 100}%)`
),
}}
/>
</div>
);
});

View File

@ -3,13 +3,7 @@
import FocusTrap from 'focus-trap-react';
import type { UIEvent } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import type { DraftBodyRanges } from '../types/BodyRange';
import type { LocalizerType } from '../types/Util';
@ -59,6 +53,7 @@ import { MessageBody } from './conversation/MessageBody';
import { RenderLocation } from './conversation/MessageTextRenderer';
import { arrow } from '../util/keyboard';
import { useElementId } from '../hooks/useUniqueId';
import { StoryProgressSegment } from './StoryProgressSegment';
function renderStrong(parts: Array<JSX.Element | string>) {
return <strong>{parts}</strong>;
@ -294,70 +289,9 @@ export function StoryViewer({
};
}, [attachment, messageId]);
const progressBarRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<Animation | null>(null);
// Putting this in a ref allows us to call it from the useEffect below without
// triggering the effect to re-run every time these values change.
const onFinishRef = useRef<(() => void) | null>(null);
useEffect(() => {
onFinishRef.current = () => {
viewStory({
storyId: story.messageId,
storyViewMode,
viewDirection: StoryViewDirectionType.Next,
});
};
}, [story.messageId, storyViewMode, viewStory]);
// This guarantees that we'll have a valid ref to the animation when we need it
strictAssert(currentIndex != null, "StoryViewer: currentIndex can't be null");
// We need to be careful about this effect refreshing, it should only run
// every time a story changes or its duration changes.
useEffect(() => {
if (!storyDuration) {
return;
}
strictAssert(
progressBarRef.current != null,
"progressBarRef can't be null"
);
const target = progressBarRef.current;
const animation = target.animate(
[{ transform: 'translateX(-100%)' }, { transform: 'translateX(0%)' }],
{
id: 'story-progress-bar',
duration: storyDuration,
easing: 'linear',
fill: 'forwards',
}
);
animationRef.current = animation;
function onFinish() {
onFinishRef.current?.();
}
animation.addEventListener('finish', onFinish);
// Reset the stuff that pauses a story when you switch story views
setConfirmDeleteStory(undefined);
setHasConfirmHideStory(false);
setHasExpandedCaption(false);
setIsSpoilerExpanded({});
setIsShowingContextMenu(false);
setPauseStory(false);
return () => {
animation.removeEventListener('finish', onFinish);
animation.cancel();
};
}, [story.messageId, storyDuration]);
const [pauseStory, setPauseStory] = useState(false);
const [pressing, setPressing] = useState(false);
const [longPress, setLongPress] = useState(false);
@ -384,9 +318,21 @@ export function StoryViewer({
}
}, [isWindowActive]);
// Reset the stuff that pauses a story when you switch story views
useEffect(() => {
setConfirmDeleteStory(undefined);
setHasConfirmHideStory(false);
setHasExpandedCaption(false);
setIsSpoilerExpanded({});
setIsShowingContextMenu(false);
setPauseStory(false);
setStoryDuration(undefined);
}, [story.messageId]);
const alertElement = renderAlert();
const shouldPauseViewing =
storyDuration == null ||
Boolean(alertElement) ||
Boolean(confirmDeleteStory) ||
currentViewTarget != null ||
@ -398,14 +344,6 @@ export function StoryViewer({
Boolean(reactionEmoji) ||
pressing;
useEffect(() => {
if (shouldPauseViewing) {
animationRef.current?.pause();
} else {
animationRef.current?.play();
}
}, [shouldPauseViewing, story.messageId, storyDuration]);
useEffect(() => {
markStoryRead(messageId);
log.info('stories.markStoryRead', { message: messageIdForLogging });
@ -890,25 +828,20 @@ export function StoryViewer({
</div>
<div className="StoryViewer__progress" {...stopPauseBubblingProps}>
{Array.from(Array(numStories), (_, index) => (
<div className="StoryViewer__progress--container" key={index}>
{currentIndex === index ? (
<div
ref={progressBarRef}
className="StoryViewer__progress--bar"
/>
) : (
<div
className="StoryViewer__progress--bar"
style={
currentIndex < index
? {}
: {
transform: 'translateX(0%)',
}
}
/>
)}
</div>
<StoryProgressSegment
key={`${story.messageId}-${index}-${currentIndex}`}
index={index}
currentIndex={currentIndex}
duration={storyDuration ?? null}
playing={!shouldPauseViewing}
onFinish={() => {
viewStory({
storyId: story.messageId,
storyViewMode,
viewDirection: StoryViewDirectionType.Next,
});
}}
/>
))}
</div>
<div className="StoryViewer__actions" {...stopPauseBubblingProps}>

View File

@ -96,6 +96,7 @@ function PlayedDot({
const start = played ? 1 : 0;
const end = played ? 0 : 1;
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [animProps] = useSpring(
{
config: SPRING_CONFIG,

View File

@ -47,6 +47,7 @@ export function TimelineFloatingHeader({
},
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
[isLoading]
);

View File

@ -87,6 +87,7 @@ function TypingBubbleAvatar({
i18n: LocalizerType;
theme: ThemeType;
}): ReactElement | null {
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [springProps, springApi] = useSpring(
{
config: SPRING_CONFIG,
@ -287,6 +288,7 @@ export function TypingBubble({
[typingContactIds]
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [outerDivStyle, outerDivSpringApi] = useSpring(
{
to: OUTER_DIV_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],
@ -294,6 +296,7 @@ export function TypingBubble({
},
[isSomeoneTyping]
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
const [typingAnimationStyle, typingAnimationSpringApi] = useSpring(
{
to: BUBBLE_ANIMATION_PROPS[isSomeoneTyping ? 'visible' : 'hidden'],

View File

@ -11,6 +11,8 @@ import {
} from '../types/Attachment';
import { count } from './grapheme';
import { SECOND } from './durations';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
const DEFAULT_DURATION = 5 * SECOND;
const MAX_VIDEO_DURATION = 30 * SECOND;
@ -42,21 +44,39 @@ export async function getStoryDuration(
if (isGIF([attachment]) || isVideo([attachment])) {
const videoEl = document.createElement('video');
if (!attachment.url) {
const { url } = attachment;
if (!url) {
return DEFAULT_DURATION;
}
videoEl.src = attachment.url;
await new Promise<void>(resolve => {
function resolveAndRemove() {
resolve();
videoEl.removeEventListener('loadedmetadata', resolveAndRemove);
}
let duration: number;
try {
duration = await new Promise<number>((resolve, reject) => {
function resolveAndRemove() {
resolve(videoEl.duration * SECOND);
videoEl.removeEventListener('loadedmetadata', resolveAndRemove);
}
videoEl.addEventListener('loadedmetadata', resolveAndRemove);
});
videoEl.addEventListener('loadedmetadata', resolveAndRemove);
videoEl.addEventListener('error', () => {
reject(videoEl.error ?? new Error('Failed to load'));
});
const duration = Math.ceil(videoEl.duration * SECOND);
videoEl.src = url;
});
} catch (error) {
log.error(
'getStoryDuration: Failed to load video duration',
Errors.toLogFormat(error)
);
return DEFAULT_DURATION;
} finally {
// Stop loading video
videoEl.pause();
videoEl.removeAttribute('src'); // empty source
videoEl.load();
}
if (isGIF([attachment])) {
// GIFs: Loop gifs 3 times or play for 5 seconds, whichever is longer.

View File

@ -2882,24 +2882,10 @@
},
{
"rule": "React-useRef",
"path": "ts/components/StoryViewer.tsx",
"line": " const progressBarRef = useRef<HTMLDivElement>(null);",
"path": "ts/components/StoryProgressSegment.tsx",
"line": " const onFinishRef = useRef(onFinish);",
"reasonCategory": "usageTrusted",
"updated": "2022-10-13T15:18:21.267Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryViewer.tsx",
"line": " const animationRef = useRef<Animation | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-10-13T15:18:21.267Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryViewer.tsx",
"line": " const onFinishRef = useRef<(() => void) | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-10-13T15:18:21.267Z"
"updated": "2024-08-13T20:48:09.226Z"
},
{
"rule": "React-useRef",