Skip to content

Commit 8885e78

Browse files
ilaswnensidosari
andauthored
feat: add animated upvotes in feed cards (#3318)
* refactor: updated scaffolding in storybook stories * feat: added @storybook/test lib for mocking actions; * feat: init ActionButtons stories; * chore: ignored .idea folder; * feat: added ActionButtons.stories.tsx; feat: updated mocks and added GB provider mock; * feat: added ActionButtons.stories.tsx; feat: updated mocks and added GB provider mock; * feat: added WIP animated arrows; * feat: added animation for ActionButtons experiment * feat: added animation for ActionButtons experiment; * fix: moved animation to right component; chore: reverted imports from package.json; * fix: updated ActionButtons moving subcomponent to top * fix: next seo import * Revert "fix: next seo import" This reverts commit 65a3f42. * fix: revert changes in next.config * fix: revert changes in next.config * feat: enabled experiment * fix: made animation faster; * fix: revert animation on webapp; * feat: added media query for reduced-motion users; * feat: check only for current added upvotes; * fix: animate only for current clicked upvotes; feat: added mocks for useConditionalFeature hook in storybook; * refactor: moved to `withExperiment` hoc mode * fix: removed default animatedUpvote flag * refactor: added `shouldEvaluate` option to `withExperiment`; * refactor: revert to `useConditionalFeature` method; * fix: revert withExperiment HOC * feat: added animation for list action buttons; * fix: revert default animated upvote feature flag; * Update packages/shared/src/components/cards/ActionsButtons/UpvoteButtonIcon.tsx Co-authored-by: nensidosari <[email protected]> * fix: test fails for missing context; * fix: linter error; --------- Co-authored-by: nensidosari <[email protected]>
1 parent 66d8320 commit 8885e78

39 files changed

+475
-143
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,5 @@ extension.xml
4343
shared.xml
4444
webapp.xml
4545

46+
# IDE
47+
.idea/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
.upvotes {
2+
3+
.upvote {
4+
--upvote-start-x: 0%;
5+
--upvote-end-x: 50%;
6+
7+
--upvote-start-y: 60%;
8+
--upvote-end-y: 0;
9+
10+
--upvote-start-rotate: 0turn;
11+
--upvote-end-rotate: 0turn;
12+
13+
--upvote-start-scale: 1;
14+
--upvote-end-scale: 1;
15+
16+
@apply absolute bottom-0;
17+
animation: upvoteToUp 1.2s cubic-bezier(0.54, -0.59, 0.38, 0.73) forwards;
18+
left: var(--upvote-start-x);
19+
opacity: 0;
20+
rotate: var(--upvote-start-rotate);
21+
top: var(--upvote-start-y);
22+
transform: translate3d(-50%, -50%, 0);
23+
will-change: left, top, transform;
24+
25+
&:nth-child(1) {
26+
@apply text-accent-avocado-default;
27+
animation-duration: .8s;
28+
--upvote-start-x: 20%;
29+
--upvote-end-x: 30%;
30+
--upvote-start-y: 70%;
31+
--upvote-end-y: 0%;
32+
--upvote-end-rotate: .015turn;
33+
}
34+
35+
&:nth-child(2) {
36+
--upvote-start-x: 70%;
37+
--upvote-end-x: 20%;
38+
--upvote-start-y: 80%;
39+
--upvote-end-y: -30%;
40+
--upvote-start-scale: 1.5;
41+
--upvote-end-scale: 1.3;
42+
--upvote-start-rotate: -.05turn;
43+
--upvote-end-rotate: .01turn;
44+
@apply text-accent-cabbage-default z-1;
45+
animation-delay: .4s;
46+
animation-duration: .4s;
47+
animation-timing-function: cubic-bezier(0.440, -0.600, 0.460, 0.965);
48+
}
49+
50+
&:nth-child(3) {
51+
@apply text-accent-blueCheese-default;
52+
animation-delay: .55s;
53+
animation-duration: .75s;
54+
--upvote-start-x: 75%;
55+
--upvote-end-x: 75%;
56+
--upvote-start-y: 100%;
57+
--upvote-end-y: -60%;
58+
--upvote-start-scale: 1.4;
59+
--upvote-end-scale: 1.6;
60+
z-index: 2;
61+
}
62+
63+
&:nth-child(4) {
64+
animation-delay: .55s;
65+
animation-duration: .75s;
66+
--upvote-start-x: 30%;
67+
--upvote-end-x: 40%;
68+
--upvote-start-y: 100%;
69+
--upvote-end-y: -30%;
70+
--upvote-start-rotate: 0;
71+
--upvote-end-rotate: .1;
72+
--upvote-start-scale: .8;
73+
--upvote-end-scale: .8;
74+
}
75+
76+
&:nth-child(5) {
77+
@apply text-accent-cheese-default;
78+
animation-delay: .45s;
79+
animation-duration: .8s;
80+
81+
--upvote-start-x: 110%;
82+
--upvote-end-x: 85%;
83+
--upvote-start-y: 100%;
84+
--upvote-end-y: -30%;
85+
--upvote-start-rotate: 0;
86+
--upvote-end-rotate: -.05turn;
87+
z-index: 1;
88+
}
89+
}
90+
}
91+
92+
@keyframes upvoteToUp {
93+
0% {
94+
left: var(--upvote-start-x);
95+
opacity: 0;
96+
rotate: var(--upvote-start-rotate);
97+
scale: 0;
98+
top: var(--upvote-start-y);
99+
}
100+
101+
15% {
102+
opacity: 1;
103+
}
104+
105+
30% {
106+
scale: var(--upvote-start-scale);
107+
}
108+
109+
50% {
110+
rotate: var(--upvote-start-rotate);
111+
}
112+
113+
95% {
114+
rotate: var(--upvote-end-rotate);
115+
}
116+
117+
99% {
118+
opacity: 1;
119+
}
120+
121+
100% {
122+
top: var(--upvote-end-y);
123+
left: var(--upvote-end-x);
124+
scale: var(--upvote-end-scale);
125+
opacity: 0;
126+
}
127+
}

packages/shared/src/components/cards/ActionButtons.tsx packages/shared/src/components/cards/ActionsButtons/ActionButtons.tsx

+20-12
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import React, { ReactElement } from 'react';
1+
import React, { ReactElement, useState } from 'react';
22
import classNames from 'classnames';
3-
import { Post, UserVote } from '../../graphql/posts';
4-
import InteractionCounter from '../InteractionCounter';
5-
import { QuaternaryButton } from '../buttons/QuaternaryButton';
3+
import { Post, UserVote } from '../../../graphql/posts';
4+
import InteractionCounter from '../../InteractionCounter';
5+
import { QuaternaryButton } from '../../buttons/QuaternaryButton';
66
import {
7-
UpvoteIcon,
87
DiscussIcon as CommentIcon,
98
BookmarkIcon,
109
LinkIcon,
11-
} from '../icons';
10+
} from '../../icons';
1211
import {
1312
Button,
1413
ButtonColor,
1514
ButtonProps,
1615
ButtonSize,
1716
ButtonVariant,
18-
} from '../buttons/Button';
19-
import { SimpleTooltip } from '../tooltips/SimpleTooltip';
20-
import { useFeedPreviewMode } from '../../hooks';
17+
} from '../../buttons/Button';
18+
import { SimpleTooltip } from '../../tooltips/SimpleTooltip';
19+
import { useFeedPreviewMode } from '../../../hooks';
20+
import { UpvoteButtonIcon } from './UpvoteButtonIcon';
2121

2222
export interface ActionButtonsProps {
2323
post: Post;
@@ -40,6 +40,8 @@ export default function ActionButtons({
4040
size: ButtonSize.Small,
4141
};
4242
const isFeedPreview = useFeedPreviewMode();
43+
const isUpvoteActive = post?.userState?.vote === UserVote.Up;
44+
const [userUpvoted, setUserUpvoted] = useState(false);
4345

4446
if (isFeedPreview) {
4547
return null;
@@ -84,10 +86,16 @@ export default function ActionButtons({
8486
<QuaternaryButton
8587
id={`post-${post.id}-upvote-btn`}
8688
icon={
87-
<UpvoteIcon secondary={post?.userState?.vote === UserVote.Up} />
89+
<UpvoteButtonIcon
90+
secondary={post?.userState?.vote === UserVote.Up}
91+
userClicked={userUpvoted}
92+
/>
8893
}
89-
pressed={post?.userState?.vote === UserVote.Up}
90-
onClick={() => onUpvoteClick?.(post)}
94+
pressed={isUpvoteActive}
95+
onClick={() => {
96+
onUpvoteClick?.(post);
97+
setUserUpvoted(true);
98+
}}
9199
{...upvoteCommentProps}
92100
className="btn-tertiary-avocado !min-w-[4.625rem]"
93101
>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { ReactElement } from 'react';
2+
import classNames from 'classnames';
3+
import { feature } from '../../../lib/featureManagement';
4+
import { UpvoteIcon } from '../../icons';
5+
import { IconProps, IconSize } from '../../Icon';
6+
import { userPrefersReducedMotions } from '../../../styles/media';
7+
import { useConditionalFeature, useMedia } from '../../../hooks';
8+
import styles from './ActionButtons.module.css';
9+
10+
type AnimatedButtonIconProps = IconProps & { userClicked?: boolean };
11+
12+
const arrows = Array.from({ length: 5 }, (_, i) => i + 1);
13+
14+
export const UpvoteButtonIcon = React.memo(function UpvoteButtonIconComp(
15+
props: AnimatedButtonIconProps,
16+
): ReactElement {
17+
const { secondary: isUpvoteActive, userClicked, ...attrs } = props;
18+
19+
const haveUserPrefersReducedMotions = useMedia(
20+
[userPrefersReducedMotions.replace('@media', '')],
21+
[false],
22+
false,
23+
false,
24+
);
25+
26+
const { value: isAnimatedVersion } = useConditionalFeature({
27+
feature: feature.animatedUpvote,
28+
shouldEvaluate:
29+
!haveUserPrefersReducedMotions && userClicked && isUpvoteActive,
30+
});
31+
32+
return (
33+
<span className="pointer-events-none relative">
34+
<UpvoteIcon secondary={isUpvoteActive} {...attrs} />
35+
{isAnimatedVersion && userClicked && isUpvoteActive && (
36+
<span
37+
aria-hidden
38+
className={classNames(
39+
styles.upvotes,
40+
'absolute left-1/2 top-0 h-full w-[125%] -translate-x-1/2',
41+
)}
42+
role="presentation"
43+
>
44+
{arrows.map((i) => (
45+
<UpvoteIcon
46+
secondary
47+
size={IconSize.XXSmall}
48+
className={styles.upvote}
49+
key={i}
50+
/>
51+
))}
52+
</span>
53+
)}
54+
</span>
55+
);
56+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import ActionButtons, { ActionButtonsProps } from './ActionButtons';
2+
3+
export type { ActionButtonsProps };
4+
export default ActionButtons;

packages/shared/src/components/cards/ArticlePostCard.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
getPostClassNames,
88
} from './Card';
99
import PostMetadata from './PostMetadata';
10-
import ActionButtons from './ActionButtons';
10+
import ActionButtons from './ActionsButtons/ActionButtons';
1111
import { PostCardHeader } from './PostCardHeader';
1212
import { PostCardFooter } from './PostCardFooter';
1313
import { Container, PostCardProps } from './common';

packages/shared/src/components/cards/CollectionCard/CollectionCard.spec.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { CollectionCard } from './CollectionCard';
55
import { sharePost as collectionPost } from '../../../../__tests__/fixture/post';
66
import { PostCardProps } from '../common';
7+
import { AuthContextProvider } from '../../../contexts/AuthContext';
78

89
const post = collectionPost;
910
const defaultProps: PostCardProps = {
@@ -31,9 +32,16 @@ beforeEach(() => {
3132

3233
const renderComponent = (props: Partial<PostCardProps> = {}): RenderResult => {
3334
return render(
34-
<QueryClientProvider client={new QueryClient()}>
35-
<CollectionCard {...defaultProps} {...props} />
36-
</QueryClientProvider>,
35+
<AuthContextProvider
36+
user={null}
37+
updateUser={jest.fn()}
38+
tokenRefreshed={false}
39+
getRedirectUri={jest.fn()}
40+
>
41+
<QueryClientProvider client={new QueryClient()}>
42+
<CollectionCard {...defaultProps} {...props} />
43+
</QueryClientProvider>
44+
</AuthContextProvider>,
3745
);
3846
};
3947

packages/shared/src/components/cards/CollectionCard/CollectionCard.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import FeedItemContainer from '../FeedItemContainer';
55
import { CollectionCardHeader } from './CollectionCardHeader';
66
import { getPostClassNames, FreeformCardTitle, CardSpace } from '../Card';
77
import { WelcomePostCardFooter } from '../WelcomePostCardFooter';
8-
import ActionButtons from '../ActionButtons';
8+
import ActionButtons from '../ActionsButtons/ActionButtons';
99
import PostMetadata from '../PostMetadata';
1010
import { usePostImage } from '../../../hooks/post/usePostImage';
1111
import CardOverlay from '../common/CardOverlay';

packages/shared/src/components/cards/SharePostCard.spec.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SharePostCard } from './SharePostCard';
55
import { sharePost } from '../../../__tests__/fixture/post';
66
import { PostCardProps } from './common';
77
import { PostType } from '../../graphql/posts';
8+
import { AuthContextProvider } from '../../contexts/AuthContext';
89

910
const post = sharePost;
1011
const defaultProps: PostCardProps = {
@@ -33,9 +34,16 @@ beforeEach(() => {
3334

3435
const renderComponent = (props: Partial<PostCardProps> = {}): RenderResult => {
3536
return render(
36-
<QueryClientProvider client={new QueryClient()}>
37-
<SharePostCard {...defaultProps} {...props} />
38-
</QueryClientProvider>,
37+
<AuthContextProvider
38+
user={null}
39+
updateUser={jest.fn()}
40+
tokenRefreshed={false}
41+
getRedirectUri={jest.fn()}
42+
>
43+
<QueryClientProvider client={new QueryClient()}>
44+
<SharePostCard {...defaultProps} {...props} />
45+
</QueryClientProvider>
46+
</AuthContextProvider>,
3947
);
4048
};
4149

packages/shared/src/components/cards/SharePostCard.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
CardTitle,
66
getPostClassNames,
77
} from './Card';
8-
import ActionButtons from './ActionButtons';
8+
import ActionButtons from './ActionsButtons/ActionButtons';
99
import { Container, PostCardProps } from './common';
1010
import FeedItemContainer from './FeedItemContainer';
1111
import { isVideoPost } from '../../graphql/posts';

packages/shared/src/components/cards/WelcomePostCard.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { forwardRef, ReactElement, Ref, useRef } from 'react';
22
import classNames from 'classnames';
33
import { FreeformCardTitle, getPostClassNames } from './Card';
4-
import ActionButtons from './ActionButtons';
4+
import ActionButtons from './ActionsButtons/ActionButtons';
55
import { Container, generateTitleClamp, PostCardProps } from './common';
66
import OptionsButton from '../buttons/OptionsButton';
77
import { WelcomePostCardFooter } from './WelcomePostCardFooter';

0 commit comments

Comments
 (0)