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

@ -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>
);
};