mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Improved repost UI (#22129)
ref https://linear.app/ghost/issue/AP-706/add-repost-count-so-that-the-ui-can-display-the-count - When you repost (or derepost) a post, the stats counter will now nicely animate into a larger (or smaller) number, with only the relevant digits animating and the others staying in place. We’re using a reusable hook for this. - Updated stat counter numbers to also change the color when the post is liked or reposted by the currently logged in user - Fixed like and post state not persisting when opening a post/note in the drawer without refreshing - Bumped the package --------- Co-authored-by: Michael Barrett <mike@ghost.org>
This commit is contained in:
parent
bf35f09826
commit
637db69ddd
6 changed files with 260 additions and 148 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.59",
|
||||
"version": "0.3.60",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -283,7 +283,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
className='ap-note-content line-clamp-[10] text-pretty leading-[1.4285714286] tracking-[-0.006em] text-grey-900'
|
||||
></div>
|
||||
{isTruncated && (
|
||||
<button className='mt-1 text-[#2563EB]' type='button'>Show more</button>
|
||||
<button className='mt-1 text-blue-600' type='button'>Show more</button>
|
||||
)}
|
||||
{renderFeedAttachment(object, layout)}
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, {useState} from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Button} from '@tryghost/admin-x-design-system';
|
||||
import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {useAnimatedCounter} from '../../hooks/useAnimatedCounter';
|
||||
import {useDerepostMutationForUser, useLikeMutationForUser, useRepostMutationForUser, useUnlikeMutationForUser} from '../../hooks/useActivityPubQueries';
|
||||
|
||||
interface FeedItemStatsProps {
|
||||
|
@ -17,17 +18,41 @@ const FeedItemStats: React.FC<FeedItemStatsProps> = ({
|
|||
object,
|
||||
likeCount,
|
||||
commentCount,
|
||||
repostCount,
|
||||
repostCount: initialRepostCount,
|
||||
layout,
|
||||
onLikeClick,
|
||||
onCommentClick
|
||||
}) => {
|
||||
const [isLiked, setIsLiked] = useState(object.liked);
|
||||
const [isReposted, setIsReposted] = useState(object.reposted);
|
||||
|
||||
// Sync with external changes - Update the liked / reposted state when the object changes
|
||||
useEffect(() => {
|
||||
setIsLiked(object.liked);
|
||||
setIsReposted(object.reposted);
|
||||
}, [object.liked, object.reposted]);
|
||||
|
||||
// Sync with external changes - Update the repost count when the initialRepostCount changes
|
||||
useEffect(() => {
|
||||
if (repostCount !== initialRepostCount) {
|
||||
if (initialRepostCount > repostCount) {
|
||||
incrementReposts();
|
||||
} else if (initialRepostCount < repostCount) {
|
||||
decrementReposts();
|
||||
}
|
||||
}
|
||||
}, [initialRepostCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const likeMutation = useLikeMutationForUser('index');
|
||||
const unlikeMutation = useUnlikeMutationForUser('index');
|
||||
const repostMutation = useRepostMutationForUser('index');
|
||||
const derepostMutation = useDerepostMutationForUser('index');
|
||||
const {
|
||||
Counter: RepostCounter,
|
||||
currentValue: repostCount,
|
||||
increment: incrementReposts,
|
||||
decrement: decrementReposts
|
||||
} = useAnimatedCounter(initialRepostCount);
|
||||
|
||||
const handleLikeClick = async (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
|
@ -40,18 +65,18 @@ const FeedItemStats: React.FC<FeedItemStatsProps> = ({
|
|||
onLikeClick();
|
||||
};
|
||||
|
||||
const buttonClassName = `transition-color flex p-2 items-center justify-center rounded-full bg-white leading-none text-grey-900 hover:bg-grey-100`;
|
||||
const buttonClassName = `transition-color flex p-2 ap-action-button items-center justify-center rounded-full bg-white leading-none hover:bg-grey-100`;
|
||||
|
||||
return (<div className={`flex ${(layout === 'inbox') ? 'flex-col' : 'gap-1'}`}>
|
||||
<Button
|
||||
className={buttonClassName}
|
||||
hideLabel={!isLiked || (layout === 'inbox')}
|
||||
className={`${buttonClassName} ${isLiked ? 'text-red-600' : 'text-grey-900'}`}
|
||||
hideLabel={true}
|
||||
icon='heart'
|
||||
iconColorClass={`w-[18px] h-[18px] ${isLiked && 'ap-red-heart text-red *:!fill-red hover:text-red'}`}
|
||||
iconColorClass={`w-[18px] h-[18px] ${isLiked && 'ap-red-heart text-red-600 *:!fill-red-600 hover:text-red-600'}`}
|
||||
id='like'
|
||||
label={new Intl.NumberFormat().format(likeCount)}
|
||||
size='md'
|
||||
title='Like'
|
||||
title={`${isLiked ? 'Undo like' : 'Like'}`}
|
||||
unstyled={true}
|
||||
onClick={(e?: React.MouseEvent<HTMLElement>) => {
|
||||
e?.stopPropagation();
|
||||
|
@ -76,12 +101,12 @@ const FeedItemStats: React.FC<FeedItemStatsProps> = ({
|
|||
}}
|
||||
/>
|
||||
<Button
|
||||
className={buttonClassName}
|
||||
hideLabel={repostCount === 0 || (layout === 'inbox')}
|
||||
className={`${buttonClassName} ${isReposted ? 'text-green-500' : 'text-grey-900'}`}
|
||||
hideLabel={(initialRepostCount === 0 && !isReposted) || repostCount === 0 || (layout === 'inbox')}
|
||||
icon='reload'
|
||||
iconColorClass={`w-[18px] h-[18px] ${isReposted && 'text-green'}`}
|
||||
iconColorClass={`w-[18px] h-[18px] ${isReposted && 'text-green-500'}`}
|
||||
id='repost'
|
||||
label={new Intl.NumberFormat().format(repostCount)}
|
||||
label={RepostCounter}
|
||||
size='md'
|
||||
title={`${isReposted ? 'Undo repost' : 'Repost'}`}
|
||||
unstyled={true}
|
||||
|
@ -90,8 +115,10 @@ const FeedItemStats: React.FC<FeedItemStatsProps> = ({
|
|||
|
||||
if (!isReposted) {
|
||||
repostMutation.mutate(object.id);
|
||||
incrementReposts();
|
||||
} else {
|
||||
derepostMutation.mutate(object.id);
|
||||
decrementReposts();
|
||||
}
|
||||
|
||||
setIsReposted(!isReposted);
|
||||
|
|
|
@ -78,37 +78,33 @@ export function useLikeMutationForUser(handle: string) {
|
|||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return api.like(id);
|
||||
},
|
||||
onMutate: (id) => {
|
||||
const previousInbox = queryClient.getQueryData([`inbox:${handle}`]);
|
||||
if (previousInbox) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryClient.setQueryData([`inbox:${handle}`], (old?: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return old?.map((item: any) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
liked: true
|
||||
}
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
onMutate: async (id) => {
|
||||
queryClient.setQueriesData([`activities:${handle}`], (current?: {pages: {data: Activity[]}[]}) => {
|
||||
if (current === undefined) {
|
||||
return current;
|
||||
}
|
||||
|
||||
// This sets the context for the onError handler
|
||||
return {previousInbox};
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousInbox) {
|
||||
queryClient.setQueryData([`inbox:${handle}`], context?.previousInbox);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({queryKey: [`liked:${handle}`]});
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page: {data: Activity[]}) => {
|
||||
return {
|
||||
...page,
|
||||
data: page.data.map((item: Activity) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
liked: true
|
||||
}
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -122,45 +118,32 @@ export function useUnlikeMutationForUser(handle: string) {
|
|||
return api.unlike(id);
|
||||
},
|
||||
onMutate: async (id) => {
|
||||
const previousInbox = queryClient.getQueryData([`inbox:${handle}`]);
|
||||
const previousLiked = queryClient.getQueryData([`liked:${handle}`]);
|
||||
queryClient.setQueriesData([`activities:${handle}`], (current?: {pages: {data: Activity[]}[]}) => {
|
||||
if (current === undefined) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (previousInbox) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryClient.setQueryData([`inbox:${handle}`], (old?: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return old?.map((item: any) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
liked: false
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page: {data: Activity[]}) => {
|
||||
return {
|
||||
...page,
|
||||
data: page.data.map((item: Activity) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
liked: false
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (previousLiked) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryClient.setQueryData([`liked:${handle}`], (old?: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return old?.filter((item: any) => item.object.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
// This sets the context for the onError handler
|
||||
return {previousInbox, previousLiked};
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousInbox) {
|
||||
queryClient.setQueryData([`inbox:${handle}`], context?.previousInbox);
|
||||
}
|
||||
if (context?.previousLiked) {
|
||||
queryClient.setQueryData([`liked:${handle}`], context?.previousLiked);
|
||||
}
|
||||
return item;
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -173,37 +156,34 @@ export function useRepostMutationForUser(handle: string) {
|
|||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return api.repost(id);
|
||||
},
|
||||
onMutate: (id) => {
|
||||
const previousInbox = queryClient.getQueryData([`inbox:${handle}`]);
|
||||
if (previousInbox) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryClient.setQueryData([`inbox:${handle}`], (old?: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return old?.map((item: any) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
reposted: true
|
||||
}
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
onMutate: async (id) => {
|
||||
queryClient.setQueriesData([`activities:${handle}`], (current?: {pages: {data: Activity[]}[]}) => {
|
||||
if (current === undefined) {
|
||||
return current;
|
||||
}
|
||||
|
||||
// This sets the context for the onError handler
|
||||
return {previousInbox};
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousInbox) {
|
||||
queryClient.setQueryData([`inbox:${handle}`], context?.previousInbox);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({queryKey: [`reposted:${handle}`]});
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page: {data: Activity[]}) => {
|
||||
return {
|
||||
...page,
|
||||
data: page.data.map((item: Activity) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
reposted: true,
|
||||
repostCount: item.object.repostCount + 1
|
||||
}
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -218,45 +198,33 @@ export function useDerepostMutationForUser(handle: string) {
|
|||
return api.derepost(id);
|
||||
},
|
||||
onMutate: async (id) => {
|
||||
const previousInbox = queryClient.getQueryData([`inbox:${handle}`]);
|
||||
const previousReposted = queryClient.getQueryData([`reposted:${handle}`]);
|
||||
queryClient.setQueriesData([`activities:${handle}`], (current?: {pages: {data: Activity[]}[]}) => {
|
||||
if (current === undefined) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (previousInbox) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryClient.setQueryData([`inbox:${handle}`], (old?: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return old?.map((item: any) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
reposted: false
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page: {data: Activity[]}) => {
|
||||
return {
|
||||
...page,
|
||||
data: page.data.map((item: Activity) => {
|
||||
if (item.object.id === id) {
|
||||
return {
|
||||
...item,
|
||||
object: {
|
||||
...item.object,
|
||||
reposted: false,
|
||||
repostCount: item.object.repostCount - 1 < 0 ? 0 : item.object.repostCount - 1
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (previousReposted) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryClient.setQueryData([`reposted:${handle}`], (old?: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return old?.filter((item: any) => item.object.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
// This sets the context for the onError handler
|
||||
return {previousInbox, previousReposted};
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousInbox) {
|
||||
queryClient.setQueryData([`inbox:${handle}`], context?.previousInbox);
|
||||
}
|
||||
if (context?.previousReposted) {
|
||||
queryClient.setQueryData([`reposted:${handle}`], context?.previousReposted);
|
||||
}
|
||||
return item;
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
67
apps/admin-x-activitypub/src/hooks/useAnimatedCounter.tsx
Normal file
67
apps/admin-x-activitypub/src/hooks/useAnimatedCounter.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
|
||||
interface AnimatedCounterResult {
|
||||
Counter: React.ReactNode;
|
||||
currentValue: number;
|
||||
increment: () => void;
|
||||
decrement: () => void;
|
||||
}
|
||||
|
||||
const splitNumber = (num: number): string[] => num.toString().split('');
|
||||
|
||||
export const useAnimatedCounter = (initialValue: number): AnimatedCounterResult => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [digits, setDigits] = useState(splitNumber(initialValue));
|
||||
const [animatingDigits, setAnimatingDigits] = useState<Set<number>>(new Set());
|
||||
const [isDecrementing, setIsDecrementing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const newDigits = splitNumber(value);
|
||||
const maxLength = Math.max(digits.length, newDigits.length);
|
||||
const changedPositions = new Set(
|
||||
Array.from({length: maxLength}, (_, i) => {
|
||||
return digits[i] !== newDigits[i] ? i : -1;
|
||||
}).filter(i => i !== -1)
|
||||
);
|
||||
|
||||
if (changedPositions.size > 0) {
|
||||
setAnimatingDigits(changedPositions);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setDigits(newDigits);
|
||||
setAnimatingDigits(new Set());
|
||||
setIsDecrementing(false);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [value, digits]);
|
||||
|
||||
const updateValue = (delta: number) => {
|
||||
setValue(prev => prev + delta);
|
||||
setIsDecrementing(delta < 0);
|
||||
};
|
||||
|
||||
return {
|
||||
Counter: (
|
||||
<span className="flex">
|
||||
{digits.map((digit, position) => (
|
||||
<span
|
||||
key={`${digits.length - position}-${digit}`}
|
||||
aria-atomic='true'
|
||||
aria-live='polite'
|
||||
className={animatingDigits.has(position)
|
||||
? isDecrementing ? 'animate-slide-down' : 'animate-slide-up'
|
||||
: ''}
|
||||
role='text'
|
||||
>
|
||||
{digit}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
currentValue: value,
|
||||
increment: () => updateValue(1),
|
||||
decrement: () => updateValue(-1)
|
||||
};
|
||||
};
|
|
@ -18,7 +18,57 @@
|
|||
}
|
||||
}
|
||||
|
||||
button.ap-like-button:active svg {
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -100%, 0);
|
||||
}
|
||||
51% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 100%, 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 100%, 0);
|
||||
}
|
||||
51% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -100%, 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-in-out;
|
||||
display: inline-block;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.3s ease-in-out;
|
||||
display: inline-block;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
button.ap-action-button:active svg {
|
||||
animation: bump 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue