Refactor StoryProgressSegment to have better controlled animations
This commit is contained in:
parent
a973c27fd4
commit
74b90a5cdd
16 changed files with 171 additions and 147 deletions
|
@ -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 = {
|
||||
|
|
26
stylesheets/components/StoryProgressSegment.scss
Normal file
26
stylesheets/components/StoryProgressSegment.scss
Normal 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%);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -124,7 +124,7 @@ export function StoryImage({
|
|||
|
||||
storyElement = (
|
||||
<video
|
||||
autoPlay
|
||||
autoPlay={!isPaused}
|
||||
className={getClassName('__image')}
|
||||
controls={false}
|
||||
key={attachment.url}
|
||||
|
|
65
ts/components/StoryProgressSegment.tsx
Normal file
65
ts/components/StoryProgressSegment.tsx
Normal 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>
|
||||
);
|
||||
});
|
|
@ -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}>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -47,6 +47,7 @@ export function TimelineFloatingHeader({
|
|||
},
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
|
||||
[isLoading]
|
||||
);
|
||||
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue