pl-fe: @react-spring/web migrations, adapted from mastodon

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-01-04 14:28:24 +01:00
parent c7643d65c5
commit 240e8a0378
12 changed files with 243 additions and 199 deletions

View File

@ -58,6 +58,7 @@
"@reach/combobox": "^0.18.0",
"@reach/rect": "^0.18.0",
"@reach/tabs": "^0.18.0",
"@react-spring/web": "^10.0.3",
"@reduxjs/toolkit": "^2.5.0",
"@sentry/browser": "^8.47.0",
"@sentry/react": "^8.47.0",

View File

@ -1,3 +1,4 @@
import { animated, type AnimatedProps } from '@react-spring/web';
import clsx from 'clsx';
import React, { useState } from 'react';
@ -13,7 +14,7 @@ interface IReaction {
announcementId: string;
reaction: AnnouncementReaction;
emojiMap: Record<string, CustomEmoji>;
style: React.CSSProperties;
style: AnimatedProps<React.ComponentProps<'button'>>['style'];
}
const Reaction: React.FC<IReaction> = ({ announcementId, reaction, emojiMap, style }) => {
@ -42,7 +43,7 @@ const Reaction: React.FC<IReaction> = ({ announcementId, reaction, emojiMap, sty
}
return (
<button
<animated.button
className={clsx('flex shrink-0 items-center gap-1.5 rounded-sm bg-gray-100 px-1.5 py-1 transition-colors dark:bg-primary-900', {
'bg-gray-200 dark:bg-primary-800': hovered,
'bg-primary-200 dark:bg-primary-500': reaction.me,
@ -59,7 +60,7 @@ const Reaction: React.FC<IReaction> = ({ announcementId, reaction, emojiMap, sty
<span className='block min-w-[9px] text-center text-xs font-medium text-primary-600 dark:text-white'>
<AnimatedNumber value={reaction.count} />
</span>
</button>
</animated.button>
);
};

View File

@ -1,5 +1,5 @@
import { useTransition } from '@react-spring/web';
import React from 'react';
import { TransitionMotion, spring } from 'react-motion';
import EmojiPickerDropdown from 'pl-fe/features/emoji/containers/emoji-picker-dropdown-container';
import { useAnnouncements } from 'pl-fe/queries/announcements/use-announcements';
@ -24,36 +24,36 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, emoj
addReaction({ announcementId, name: (data as NativeEmoji).native.replace(/:/g, '') });
};
const willEnter = () => ({ scale: reduceMotion ? 1 : 0 });
const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) });
const visibleReactions = reactions.filter(x => x.count > 0);
const styles = visibleReactions.map(reaction => ({
key: reaction.name,
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
}));
const transitions = useTransition(visibleReactions, {
from: {
scale: 0,
},
enter: {
scale: 1,
},
leave: {
scale: 0,
},
immediate: reduceMotion,
keys: visibleReactions.map(x => x.name),
});
return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<div className='flex flex-wrap items-center gap-1'>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={announcementId}
emojiMap={emojiMap}
/>
))}
<div className='flex flex-wrap items-center gap-1'>
{transitions(({ scale }, reaction) => (
<Reaction
key={reaction.name}
reaction={reaction}
style={{ transform: scale.to((s) => `scale(${s})`) }}
announcementId={announcementId}
emojiMap={emojiMap}
/>
))}
{visibleReactions.length < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />}
</div>
)}
</TransitionMotion>
{visibleReactions.length < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />}
</div>
);
};

View File

@ -1,7 +1,7 @@
import { animated, config, useSpring } from '@react-spring/web';
import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Motion, presets, spring } from 'react-motion';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
@ -16,16 +16,20 @@ const messages = defineMessages({
votes: { id: 'poll.votes', defaultMessage: '{votes, plural, one {# vote} other {# votes}}' },
});
const PollPercentageBar: React.FC<{ percent: number; leading: boolean }> = ({ percent, leading }): JSX.Element => (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { ...presets.gentle, precision: 0.1 }) }}>
{({ width }) => (
<span
className='absolute inset-0 inline-block h-full rounded-l-md bg-primary-100 dark:bg-primary-500'
style={{ width: `${width}%` }}
/>
)}
</Motion>
);
const PollPercentageBar: React.FC<{ percent: number; leading: boolean }> = ({ percent, leading }): JSX.Element => {
const styles = useSpring({
from: { width: '0%' },
to: { width: `${percent}%` },
config: config.gentle,
});
return (
<animated.span
className='absolute inset-0 inline-block h-full rounded-l-md bg-primary-100 dark:bg-primary-500'
style={styles}
/>
);
};
interface IPollOptionText extends IPollOption {
percent: number;

View File

@ -1,8 +1,8 @@
import { animated, useSpring } from '@react-spring/web';
import clsx from 'clsx';
import React from 'react';
import { spring } from 'react-motion';
import Motion from 'pl-fe/features/ui/util/optional-motion';
import { useSettings } from 'pl-fe/stores/settings';
interface IProgressBar {
/** Number between 0 and 1 to represent the percentage complete. */
@ -12,22 +12,29 @@ interface IProgressBar {
}
/** A horizontal meter filled to the given percentage. */
const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => (
<div
className={clsx('h-2.5 w-full overflow-hidden rounded-lg bg-gray-300 dark:bg-primary-800', {
'h-2.5': size === 'md',
'h-[6px]': size === 'sm',
})}
>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress * 100) }}>
{({ width }) => (
<div
className='h-full bg-secondary-500'
style={{ width: `${width}%` }}
/>
)}
</Motion>
</div>
);
const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => {
const { reduceMotion } = useSettings();
const styles = useSpring({
from: { width: '0%' },
to: { width: `${progress}%` },
reset: true,
immediate: reduceMotion,
});
return (
<div
className={clsx('h-2.5 w-full overflow-hidden rounded-lg bg-gray-300 dark:bg-primary-800', {
'h-2.5': size === 'md',
'h-[6px]': size === 'sm',
})}
>
<animated.div
className='h-full bg-secondary-500'
style={styles}
/>
</div>
);
};
export { ProgressBar as default };

View File

@ -20,18 +20,18 @@ import presentationIcon from '@phosphor-icons/core/regular/presentation.svg';
import spreadsheetIcon from '@phosphor-icons/core/regular/table.svg';
import videoIcon from '@phosphor-icons/core/regular/video.svg';
import xIcon from '@phosphor-icons/core/regular/x.svg';
import { animated, config, useSpring } from '@react-spring/web';
import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { spring } from 'react-motion';
import AltIndicator from 'pl-fe/components/alt-indicator';
import Blurhash from 'pl-fe/components/blurhash';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import IconButton from 'pl-fe/components/ui/icon-button';
import Motion from 'pl-fe/features/ui/util/optional-motion';
import { useModalsActions } from 'pl-fe/stores/modals';
import { useSettings } from 'pl-fe/stores/settings';
import type { MediaAttachment } from 'pl-api';
@ -103,6 +103,7 @@ const Upload: React.FC<IUpload> = ({
}) => {
const intl = useIntl();
const { openModal } = useModalsActions();
const { reduceMotion } = useSettings();
const handleUndoClick: React.MouseEventHandler = e => {
if (onDelete) {
@ -142,6 +143,15 @@ const Upload: React.FC<IUpload> = ({
const mediaType = media.type;
const mimeType = media.mime_type as string | undefined;
const styles = useSpring({
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined,
from: { scale: 0.8 },
to: { scale: 1 },
config: config.stiff,
immediate: reduceMotion,
});
const uploadIcon = mediaType === 'unknown' && (
<Icon
className='mx-auto my-12 size-16 text-gray-800 dark:text-gray-200'
@ -160,75 +170,67 @@ const Upload: React.FC<IUpload> = ({
onDragEnd={onDragEnd}
>
<Blurhash hash={media.blurhash} className='⁂-media-gallery__preview' />
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div
className={clsx('compose-form__upload-thumbnail relative h-40 w-full overflow-hidden bg-contain bg-center bg-no-repeat', mediaType)}
style={{
transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined,
}}
>
<HStack className='absolute right-2 top-2 z-10' space={2}>
{onDescriptionChange && (
<IconButton
onClick={handleOpenAltTextModal}
src={editIcon}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.description)}
/>
)}
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
src={zoomInIcon}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.preview)}
/>
)}
{onDelete && (
<IconButton
onClick={handleUndoClick}
src={xIcon}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.delete)}
/>
)}
</HStack>
<animated.div
className={clsx('compose-form__upload-thumbnail relative h-40 w-full overflow-hidden bg-contain bg-center bg-no-repeat', mediaType)}
style={styles}
>
<HStack className='absolute right-2 top-2 z-10' space={2}>
{onDescriptionChange && (
<IconButton
onClick={handleOpenAltTextModal}
src={editIcon}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.description)}
/>
)}
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
src={zoomInIcon}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.preview)}
/>
)}
{onDelete && (
<IconButton
onClick={handleUndoClick}
src={xIcon}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.delete)}
/>
)}
</HStack>
<HStack space={2} justifyContent='between' className='absolute inset-x-2 bottom-2 z-10'>
<span className='overflow-hidden text-ellipsis rounded bg-gray-900 px-2 py-1 text-xs font-medium text-white'>
{media.url.split('/').at(-1)}
</span>
<HStack space={2} justifyContent='between' className='absolute inset-x-2 bottom-2 z-10'>
<span className='overflow-hidden text-ellipsis rounded bg-gray-900 px-2 py-1 text-xs font-medium text-white'>
{media.url.split('/').at(-1)}
</span>
{onDescriptionChange && !description && (
<button onClick={handleOpenAltTextModal}>
<AltIndicator
warning
title={intl.formatMessage(messages.descriptionMissingTitle)}
/>
</button>
)}
</HStack>
{onDescriptionChange && !description && (
<button onClick={handleOpenAltTextModal}>
<AltIndicator
warning
title={intl.formatMessage(messages.descriptionMissingTitle)}
/>
</button>
)}
</HStack>
<div className='absolute inset-0 z-[-1] size-full'>
{mediaType === 'video' && (
<video className='size-full object-cover' autoPlay playsInline muted loop>
<source src={media.preview_url} />
</video>
)}
{uploadIcon}
</div>
</div>
)}
</Motion>
<div className='absolute inset-0 z-[-1] size-full'>
{mediaType === 'video' && (
<video className='size-full object-cover' autoPlay playsInline muted loop>
<source src={media.preview_url} />
</video>
)}
{uploadIcon}
</div>
</animated.div>
</div>
);
};

View File

@ -1,13 +1,13 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import Button from 'pl-fe/components/ui/button';
import HStack from 'pl-fe/components/ui/hstack';
import Stack from 'pl-fe/components/ui/stack';
import OptionalMotion from 'pl-fe/features/ui/util/optional-motion';
import { useCompose } from 'pl-fe/hooks/use-compose';
import Warning from './warning';
interface IClearLinkSuggestion {
composeId: string;
handleAccept: (key: string) => void;
@ -25,9 +25,10 @@ const ClearLinkSuggestion = ({
if (!suggestion) return null;
return (
<OptionalMotion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<Stack space={1} className='rounded border border-solid border-gray-400 bg-transparent px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<Warning
animated
message={
<Stack space={1}>
<span>
<FormattedMessage
id='compose.clear_link_suggestion.body'
@ -52,8 +53,8 @@ const ClearLinkSuggestion = ({
</Button>
</HStack>
</Stack>
)}
</OptionalMotion>
}
/>
);
};

View File

@ -1,17 +1,17 @@
import React from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { ignoreHashtagCasingSuggestion } from 'pl-fe/actions/compose';
import { changeSetting } from 'pl-fe/actions/settings';
import Button from 'pl-fe/components/ui/button';
import HStack from 'pl-fe/components/ui/hstack';
import Stack from 'pl-fe/components/ui/stack';
import OptionalMotion from 'pl-fe/features/ui/util/optional-motion';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useCompose } from 'pl-fe/hooks/use-compose';
import toast from 'pl-fe/toast';
import Warning from './warning';
const messages = defineMessages({
hashtagCasingSuggestionsDisabled: { id: 'compose.hashtag_casing_suggestion.disabled', defaultMessage: 'You will no longer receive suggestions about hashtag capitalization.' },
});
@ -41,9 +41,10 @@ const HashtagCasingSuggestion = ({
if (!suggestion) return null;
return (
<OptionalMotion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<Stack space={1} className='rounded border border-solid border-gray-400 bg-transparent px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<Warning
animated
message={
<Stack space={1}>
<span>
<FormattedMessage
id='compose.hashtag_casing_suggestion.body'
@ -68,8 +69,8 @@ const HashtagCasingSuggestion = ({
</Button>
</HStack>
</Stack>
)}
</OptionalMotion>
}
/>
);
};

View File

@ -1,7 +1,7 @@
import { animated, useSpring } from '@react-spring/web';
import React from 'react';
import { spring } from 'react-motion';
import Motion from '../../ui/util/optional-motion';
import { useSettings } from 'pl-fe/stores/settings';
interface IWarning {
message: React.ReactNode;
@ -9,17 +9,29 @@ interface IWarning {
}
/** Warning message displayed in ComposeForm. */
const Warning: React.FC<IWarning> = ({ message, animated }) => {
const Warning: React.FC<IWarning> = ({ message, animated: animate }) => {
const { reduceMotion } = useSettings();
const styles = useSpring({
from: {
opacity: 0,
transform: 'scale(0.85, 0.75)',
},
to: {
opacity: 1,
transform: 'scale(1, 1)',
},
immediate: !animate || reduceMotion,
});
const className = 'rounded border border-solid border-gray-400 bg-transparent px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white';
if (animated) return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className={className} style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
if (!message) return null;
if (animate) return (
<animated.div className={className} style={styles}>
{message}
</animated.div>
);
return (

View File

@ -1,16 +0,0 @@
import React from 'react';
import { Motion, MotionProps } from 'react-motion';
import { useSettings } from 'pl-fe/stores/settings';
import ReducedMotion from './reduced-motion';
const OptionalMotion = (props: MotionProps) => {
const { reduceMotion } = useSettings();
return (
reduceMotion ? <ReducedMotion {...props} /> : <Motion {...props} />
);
};
export default OptionalMotion;

View File

@ -1,31 +0,0 @@
// Like react-motion's Motion, but reduces all animations to cross-fades
// for the benefit of users with motion sickness.
import React from 'react';
import { Motion, MotionProps } from 'react-motion';
const stylesToKeep = ['opacity', 'backgroundOpacity'];
const extractValue = (value: any) => {
// This is either an object with a "val" property or it's a number
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
};
const ReducedMotion: React.FC<MotionProps> = ({ style = {}, defaultStyle = {}, children }) => {
Object.keys(style).forEach(key => {
if (stylesToKeep.includes(key)) {
return;
}
// If it's setting an x or height or scale or some other value, we need
// to preserve the end-state value without actually animating it
style[key] = defaultStyle[key] = extractValue(style[key]);
});
return (
<Motion style={style} defaultStyle={defaultStyle}>
{children}
</Motion>
);
};
export default ReducedMotion;