diff --git a/packages/pl-fe/package.json b/packages/pl-fe/package.json index 94c8f3a97..fbcd3fa7c 100644 --- a/packages/pl-fe/package.json +++ b/packages/pl-fe/package.json @@ -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", diff --git a/packages/pl-fe/src/components/announcements/reaction.tsx b/packages/pl-fe/src/components/announcements/reaction.tsx index ac6d817c2..3829128b6 100644 --- a/packages/pl-fe/src/components/announcements/reaction.tsx +++ b/packages/pl-fe/src/components/announcements/reaction.tsx @@ -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; - style: React.CSSProperties; + style: AnimatedProps>['style']; } const Reaction: React.FC = ({ announcementId, reaction, emojiMap, style }) => { @@ -42,7 +43,7 @@ const Reaction: React.FC = ({ announcementId, reaction, emojiMap, sty } return ( - + ); }; diff --git a/packages/pl-fe/src/components/announcements/reactions-bar.tsx b/packages/pl-fe/src/components/announcements/reactions-bar.tsx index b3a029ecd..6296cd7b1 100644 --- a/packages/pl-fe/src/components/announcements/reactions-bar.tsx +++ b/packages/pl-fe/src/components/announcements/reactions-bar.tsx @@ -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 = ({ 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 ( - - {items => ( -
- {items.map(({ key, data, style }) => ( - - ))} +
+ {transitions(({ scale }, reaction) => ( + `scale(${s})`) }} + announcementId={announcementId} + emojiMap={emojiMap} + /> + ))} - {visibleReactions.length < 8 && } -
- )} - + {visibleReactions.length < 8 && } +
); }; diff --git a/packages/pl-fe/src/components/polls/poll-option.tsx b/packages/pl-fe/src/components/polls/poll-option.tsx index 10a9b3def..48a941176 100644 --- a/packages/pl-fe/src/components/polls/poll-option.tsx +++ b/packages/pl-fe/src/components/polls/poll-option.tsx @@ -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 => ( - - {({ width }) => ( - - )} - -); +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 ( + + ); +}; interface IPollOptionText extends IPollOption { percent: number; diff --git a/packages/pl-fe/src/components/ui/progress-bar.tsx b/packages/pl-fe/src/components/ui/progress-bar.tsx index dc431667a..c09b562e2 100644 --- a/packages/pl-fe/src/components/ui/progress-bar.tsx +++ b/packages/pl-fe/src/components/ui/progress-bar.tsx @@ -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 = ({ progress, size = 'md' }) => ( -
- - {({ width }) => ( -
- )} - -
-); +const ProgressBar: React.FC = ({ progress, size = 'md' }) => { + const { reduceMotion } = useSettings(); + + const styles = useSpring({ + from: { width: '0%' }, + to: { width: `${progress}%` }, + reset: true, + immediate: reduceMotion, + }); + + return ( +
+ +
+ ); +}; export { ProgressBar as default }; diff --git a/packages/pl-fe/src/components/upload.tsx b/packages/pl-fe/src/components/upload.tsx index 311a6e37f..ced9350c5 100644 --- a/packages/pl-fe/src/components/upload.tsx +++ b/packages/pl-fe/src/components/upload.tsx @@ -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 = ({ }) => { const intl = useIntl(); const { openModal } = useModalsActions(); + const { reduceMotion } = useSettings(); const handleUndoClick: React.MouseEventHandler = e => { if (onDelete) { @@ -142,6 +143,15 @@ const Upload: React.FC = ({ 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' && ( = ({ onDragEnd={onDragEnd} > - - {({ scale }) => ( -
- - {onDescriptionChange && ( - - )} - {(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && ( - - )} - {onDelete && ( - - )} - + + + {onDescriptionChange && ( + + )} + {(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && ( + + )} + {onDelete && ( + + )} + - - - {media.url.split('/').at(-1)} - + + + {media.url.split('/').at(-1)} + - {onDescriptionChange && !description && ( - - )} - + {onDescriptionChange && !description && ( + + )} + -
- {mediaType === 'video' && ( - - )} - {uploadIcon} -
-
- )} -
+
+ {mediaType === 'video' && ( + + )} + {uploadIcon} +
+
); }; diff --git a/packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx b/packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx index 5a4f08ce1..1766d96af 100644 --- a/packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx +++ b/packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx @@ -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 ( - - {({ opacity, scaleX, scaleY }) => ( - + - )} - + } + /> ); }; diff --git a/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx b/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx index 883f246ce..11ff271e1 100644 --- a/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx +++ b/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx @@ -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 ( - - {({ opacity, scaleX, scaleY }) => ( - + - )} - + } + /> ); }; diff --git a/packages/pl-fe/src/features/compose/components/warning.tsx b/packages/pl-fe/src/features/compose/components/warning.tsx index 64134f3e9..6315524dd 100644 --- a/packages/pl-fe/src/features/compose/components/warning.tsx +++ b/packages/pl-fe/src/features/compose/components/warning.tsx @@ -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 = ({ message, animated }) => { +const Warning: React.FC = ({ 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 ( - - {({ opacity, scaleX, scaleY }) => ( -
- {message} -
- )} -
+ if (!message) return null; + + if (animate) return ( + + {message} + ); return ( diff --git a/packages/pl-fe/src/features/ui/util/optional-motion.tsx b/packages/pl-fe/src/features/ui/util/optional-motion.tsx deleted file mode 100644 index d18322a71..000000000 --- a/packages/pl-fe/src/features/ui/util/optional-motion.tsx +++ /dev/null @@ -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 ? : - ); -}; - -export default OptionalMotion; diff --git a/packages/pl-fe/src/features/ui/util/reduced-motion.tsx b/packages/pl-fe/src/features/ui/util/reduced-motion.tsx deleted file mode 100644 index c677f4428..000000000 --- a/packages/pl-fe/src/features/ui/util/reduced-motion.tsx +++ /dev/null @@ -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 = ({ 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 ( - - {children} - - ); -}; - -export default ReducedMotion; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7735afe58..6b2f5729c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: '@reach/tabs': specifier: ^0.18.0 version: 0.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-spring/web': + specifier: ^10.0.3 + version: 10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@reduxjs/toolkit': specifier: ^2.5.0 version: 2.8.2(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -2060,6 +2063,33 @@ packages: react: ^16.8.0 || 17.x react-dom: ^16.8.0 || 17.x + '@react-spring/animated@10.0.3': + resolution: {integrity: sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-spring/core@10.0.3': + resolution: {integrity: sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-spring/rafz@10.0.3': + resolution: {integrity: sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==} + + '@react-spring/shared@10.0.3': + resolution: {integrity: sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-spring/types@10.0.3': + resolution: {integrity: sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==} + + '@react-spring/web@10.0.3': + resolution: {integrity: sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@reduxjs/toolkit@2.8.2': resolution: {integrity: sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==} peerDependencies: @@ -8579,6 +8609,38 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@react-spring/animated@10.0.3(react@18.3.1)': + dependencies: + '@react-spring/shared': 10.0.3(react@18.3.1) + '@react-spring/types': 10.0.3 + react: 18.3.1 + + '@react-spring/core@10.0.3(react@18.3.1)': + dependencies: + '@react-spring/animated': 10.0.3(react@18.3.1) + '@react-spring/shared': 10.0.3(react@18.3.1) + '@react-spring/types': 10.0.3 + react: 18.3.1 + + '@react-spring/rafz@10.0.3': {} + + '@react-spring/shared@10.0.3(react@18.3.1)': + dependencies: + '@react-spring/rafz': 10.0.3 + '@react-spring/types': 10.0.3 + react: 18.3.1 + + '@react-spring/types@10.0.3': {} + + '@react-spring/web@10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-spring/animated': 10.0.3(react@18.3.1) + '@react-spring/core': 10.0.3(react@18.3.1) + '@react-spring/shared': 10.0.3(react@18.3.1) + '@react-spring/types': 10.0.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@reduxjs/toolkit@2.8.2(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': dependencies: '@standard-schema/spec': 1.0.0