0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Added “unfollow” functionality to FollowButton (#22057)

ref https://linear.app/ghost/issue/AP-590/unable-to-unfollow-accounts

- Users can now unfollow accounts they’re following, which means that account will be removed from the user’s “following” list and any of the future posts or notes published by that account won’t appear in user’s inbox or feed.
- Refactored and simplified `FollowButton` so it only has 2 variants: primary (used on profiles, where it's the primary focus of the screen) and secondary (used in lists where there will probably be lots of `FollowButton`s next to each other.)

---------

Co-authored-by: Fabien O'Carroll <fabien@allou.is>
This commit is contained in:
Djordje Vlaisavljevic 2025-01-30 12:00:56 +00:00 committed by GitHub
parent 99799d214c
commit 0cdec925ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 115 additions and 35 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.3.54",
"version": "0.3.55",
"license": "MIT",
"repository": {
"type": "git",

View file

@ -69,7 +69,7 @@ export type AccountFollowsType = 'following' | 'followers';
type GetAccountResponse = Account
export type FollowAccount = Pick<Account, 'id' | 'name' | 'handle' | 'avatarUrl'>;
export type FollowAccount = Pick<Account, 'id' | 'name' | 'handle' | 'avatarUrl'> & {isFollowing: true};
export interface GetAccountFollowsResponse {
accounts: FollowAccount[];
@ -195,6 +195,12 @@ export class ActivityPubAPI {
return json as Actor;
}
async unfollow(username: string): Promise<Actor> {
const url = new URL(`.ghost/activitypub/actions/unfollow/${username}`, this.apiUrl);
const json = await this.fetchJSON(url, 'POST');
return json as Actor;
}
get likedApiUrl() {
return new URL(`.ghost/activitypub/liked/${this.handle}`, this.apiUrl);
}

View file

@ -18,6 +18,7 @@ import {handleViewContent} from '../utils/content-handlers';
import APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem';
import FeedItem from './feed/FeedItem';
import FollowButton from './global/FollowButton';
import MainNavigation from './navigation/MainNavigation';
import Separator from './global/Separator';
import ViewProfileModal from './modals/ViewProfileModal';
@ -211,6 +212,12 @@ const FollowingTab: React.FC = () => {
<div className='text-sm'>{account.handle}</div>
</div>
</div>
<FollowButton
className='ml-auto'
following={account.isFollowing}
handle={account.handle}
type='secondary'
/>
</ActivityItem>
{index < accounts.length - 1 && <Separator />}
</React.Fragment>
@ -253,6 +260,12 @@ const FollowersTab: React.FC = () => {
<div className='text-sm'>{account.handle}</div>
</div>
</div>
<FollowButton
className='ml-auto'
following={account.isFollowing}
handle={account.handle}
type='secondary'
/>
</ActivityItem>
{index < accounts.length - 1 && <Separator />}
</React.Fragment>

View file

@ -57,17 +57,15 @@ const AccountSearchResultItem: React.FC<AccountSearchResultItemProps> = ({accoun
name: account.name,
handle: account.handle
}}/>
<div>
<div className='text-grey-600'>
<span className='font-semibold text-black'>{account.name} </span>{account.handle}
</div>
<div className='text-sm'>{new Intl.NumberFormat().format(account.followerCount)} followers</div>
<div className='flex flex-col'>
<span className='font-semibold text-black'>{account.name}</span>
<span className='text-sm text-grey-700'>{account.handle}</span>
</div>
<FollowButton
className='ml-auto'
following={account.followedByMe}
handle={account.handle}
type='link'
type='secondary'
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
@ -122,17 +120,15 @@ const SuggestedProfile: React.FC<SuggestedProfileProps> = ({profile, update}) =>
}}
>
<APAvatar author={profile.actor}/>
<div>
<div className='text-grey-600'>
<span className='font-semibold text-black'>{profile.actor.name} </span>{profile.handle}
</div>
<div className='text-sm'>{new Intl.NumberFormat().format(profile.followerCount)} followers</div>
<div className='flex flex-col'>
<span className='font-semibold text-black'>{profile.actor.name}</span>
<span className='text-sm text-grey-700'>{profile.handle}</span>
</div>
<FollowButton
className='ml-auto'
following={profile.isFollowing}
handle={profile.handle}
type='link'
type='secondary'
onFollow={onFollow}
onUnfollow={onUnfollow}
/>

View file

@ -23,12 +23,12 @@ const ActivityItem: React.FC<ActivityItemProps> = ({children, url = null, onClic
const childrenArray = React.Children.toArray(children);
const Item = (
<div className='relative flex w-full max-w-[560px] cursor-pointer flex-col before:absolute before:inset-x-[-8px] before:inset-y-[-1px] before:rounded-md before:bg-grey-50 before:opacity-0 before:transition-opacity hover:z-10 hover:cursor-pointer hover:border-b-transparent hover:before:opacity-100 dark:before:bg-grey-950' onClick={() => {
<div className='relative flex w-full max-w-[560px] cursor-pointer flex-col before:absolute before:inset-x-[-16px] before:inset-y-[-1px] before:rounded-md before:bg-grey-50 before:opacity-0 before:transition-opacity hover:z-10 hover:cursor-pointer hover:border-b-transparent hover:before:opacity-100 dark:before:bg-grey-950' onClick={() => {
if (!url && onClick) {
onClick();
}
}}>
<div className='relative z-10 flex w-full gap-3 px-2 py-4'>
<div className='relative z-10 flex w-full items-center gap-3 py-4'>
{childrenArray[0]}
{childrenArray[1]}
{childrenArray[2]}

View file

@ -1,14 +1,13 @@
import {useEffect, useState} from 'react';
import clsx from 'clsx';
import {Button} from '@tryghost/admin-x-design-system';
import {useFollow} from '../../hooks/useActivityPubQueries';
import {useEffect, useState} from 'react';
import {useFollow, useUnfollow} from '../../hooks/useActivityPubQueries';
interface FollowButtonProps {
className?: string;
following: boolean;
handle: string;
type?: 'button' | 'link';
type?: 'primary' | 'secondary';
onFollow?: () => void;
onUnfollow?: () => void;
}
@ -19,13 +18,22 @@ const FollowButton: React.FC<FollowButtonProps> = ({
className,
following,
handle,
type = 'button',
type = 'secondary',
onFollow = noop,
onUnfollow = noop
}) => {
const [isFollowing, setIsFollowing] = useState(following);
const [isHovered, setIsHovered] = useState(false);
const mutation = useFollow('index',
const unfollowMutation = useUnfollow('index',
noop,
() => {
setIsFollowing(false);
onUnfollow();
}
);
const followMutation = useFollow('index',
noop,
() => {
setIsFollowing(false);
@ -37,13 +45,11 @@ const FollowButton: React.FC<FollowButtonProps> = ({
if (isFollowing) {
setIsFollowing(false);
onUnfollow();
// @TODO: Implement unfollow mutation
unfollowMutation.mutate(handle);
} else {
setIsFollowing(true);
onFollow();
mutation.mutate(handle);
followMutation.mutate(handle);
}
};
@ -51,19 +57,26 @@ const FollowButton: React.FC<FollowButtonProps> = ({
setIsFollowing(following);
}, [following]);
const color = (type === 'primary') ? 'black' : 'grey';
const size = (type === 'primary') ? 'md' : 'sm';
const minWidth = (type === 'primary') ? 'min-w-[96px]' : 'min-w-[88px]';
return (
<Button
className={className}
color='black'
disabled={isFollowing}
label={isFollowing ? 'Following' : 'Follow'}
link={type === 'link'}
className={clsx(
className,
isFollowing && minWidth
)}
color={isFollowing ? 'outline' : color}
label={isFollowing ? (isHovered ? 'Unfollow' : 'Following') : 'Follow'}
size={size}
onClick={(event) => {
event?.preventDefault();
event?.stopPropagation();
handleClick();
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
);
};

View file

@ -98,7 +98,7 @@ const ActorList: React.FC<ActorListProps> = ({
className='ml-auto'
following={isFollowing}
handle={getUsername(actor)}
type='link'
type='secondary'
/>
</ActivityItem>
{index < actors.length - 1 && <Separator />}
@ -326,6 +326,7 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<FollowButton
following={profile.isFollowing}
handle={profile.handle}
type='primary'
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
@ -347,9 +348,10 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<div className='absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white via-white/90 via-60% to-transparent' />
)}
{isOverflowing && <Button
className='absolute bottom-0 text-green'
className='absolute bottom-0'
label={isExpanded ? 'Show less' : 'Show all'}
link={true}
size='sm'
onClick={toggleExpand}
/>}
</div>)}

View file

@ -4,6 +4,8 @@ import {
ActivityPubAPI,
ActivityPubCollectionResponse,
ActivityThread,
Actor,
FollowAccount,
type GetAccountFollowsResponse,
type Profile,
type SearchResults
@ -202,6 +204,52 @@ export function useFollowingForUser(handle: string) {
});
}
export function useUnfollow(handle: string, onSuccess: () => void, onError: () => void) {
const queryClient = useQueryClient();
return useMutation({
async mutationFn(username: string) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.unfollow(username);
},
onSuccess(unfollowedActor, fullHandle) {
queryClient.setQueryData([`profile:${fullHandle}`], (currentProfile: unknown) => {
if (!currentProfile) {
return currentProfile;
}
return {
...currentProfile,
isFollowing: false
};
});
queryClient.setQueryData(['following:index'], (currentFollowing?: Actor[]) => {
if (!currentFollowing) {
return currentFollowing;
}
return currentFollowing.filter(item => item.id !== unfollowedActor.id);
});
queryClient.setQueryData(['follows:index:following'], (currentFollowing?: FollowAccount[]) => {
if (!currentFollowing) {
return currentFollowing;
}
return currentFollowing.filter(item => item.id !== unfollowedActor.id);
});
queryClient.setQueryData(['followingCount:index'], (currentFollowingCount?: number) => {
if (!currentFollowingCount) {
return 0;
}
return currentFollowingCount - 1;
});
onSuccess();
},
onError
});
}
export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
const queryClient = useQueryClient();
return useMutation({
@ -228,6 +276,8 @@ export function useFollow(handle: string, onSuccess: () => void, onError: () =>
return [followedActor].concat(currentFollowing);
});
queryClient.invalidateQueries(['follows:index:following']);
queryClient.setQueryData(['followingCount:index'], (currentFollowingCount?: number) => {
if (!currentFollowingCount) {
return 1;