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 ()

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", "name": "@tryghost/admin-x-activitypub",
"version": "0.3.54", "version": "0.3.55",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -69,7 +69,7 @@ export type AccountFollowsType = 'following' | 'followers';
type GetAccountResponse = Account 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 { export interface GetAccountFollowsResponse {
accounts: FollowAccount[]; accounts: FollowAccount[];
@ -195,6 +195,12 @@ export class ActivityPubAPI {
return json as Actor; 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() { get likedApiUrl() {
return new URL(`.ghost/activitypub/liked/${this.handle}`, this.apiUrl); 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 APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem'; import ActivityItem from './activities/ActivityItem';
import FeedItem from './feed/FeedItem'; import FeedItem from './feed/FeedItem';
import FollowButton from './global/FollowButton';
import MainNavigation from './navigation/MainNavigation'; import MainNavigation from './navigation/MainNavigation';
import Separator from './global/Separator'; import Separator from './global/Separator';
import ViewProfileModal from './modals/ViewProfileModal'; import ViewProfileModal from './modals/ViewProfileModal';
@ -211,6 +212,12 @@ const FollowingTab: React.FC = () => {
<div className='text-sm'>{account.handle}</div> <div className='text-sm'>{account.handle}</div>
</div> </div>
</div> </div>
<FollowButton
className='ml-auto'
following={account.isFollowing}
handle={account.handle}
type='secondary'
/>
</ActivityItem> </ActivityItem>
{index < accounts.length - 1 && <Separator />} {index < accounts.length - 1 && <Separator />}
</React.Fragment> </React.Fragment>
@ -253,6 +260,12 @@ const FollowersTab: React.FC = () => {
<div className='text-sm'>{account.handle}</div> <div className='text-sm'>{account.handle}</div>
</div> </div>
</div> </div>
<FollowButton
className='ml-auto'
following={account.isFollowing}
handle={account.handle}
type='secondary'
/>
</ActivityItem> </ActivityItem>
{index < accounts.length - 1 && <Separator />} {index < accounts.length - 1 && <Separator />}
</React.Fragment> </React.Fragment>

View file

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

View file

@ -23,12 +23,12 @@ const ActivityItem: React.FC<ActivityItemProps> = ({children, url = null, onClic
const childrenArray = React.Children.toArray(children); const childrenArray = React.Children.toArray(children);
const Item = ( 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) { if (!url && onClick) {
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[0]}
{childrenArray[1]} {childrenArray[1]}
{childrenArray[2]} {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 {Button} from '@tryghost/admin-x-design-system';
import {useEffect, useState} from 'react';
import {useFollow} from '../../hooks/useActivityPubQueries'; import {useFollow, useUnfollow} from '../../hooks/useActivityPubQueries';
interface FollowButtonProps { interface FollowButtonProps {
className?: string; className?: string;
following: boolean; following: boolean;
handle: string; handle: string;
type?: 'button' | 'link'; type?: 'primary' | 'secondary';
onFollow?: () => void; onFollow?: () => void;
onUnfollow?: () => void; onUnfollow?: () => void;
} }
@ -19,13 +18,22 @@ const FollowButton: React.FC<FollowButtonProps> = ({
className, className,
following, following,
handle, handle,
type = 'button', type = 'secondary',
onFollow = noop, onFollow = noop,
onUnfollow = noop onUnfollow = noop
}) => { }) => {
const [isFollowing, setIsFollowing] = useState(following); 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, noop,
() => { () => {
setIsFollowing(false); setIsFollowing(false);
@ -37,13 +45,11 @@ const FollowButton: React.FC<FollowButtonProps> = ({
if (isFollowing) { if (isFollowing) {
setIsFollowing(false); setIsFollowing(false);
onUnfollow(); onUnfollow();
unfollowMutation.mutate(handle);
// @TODO: Implement unfollow mutation
} else { } else {
setIsFollowing(true); setIsFollowing(true);
onFollow(); onFollow();
followMutation.mutate(handle);
mutation.mutate(handle);
} }
}; };
@ -51,19 +57,26 @@ const FollowButton: React.FC<FollowButtonProps> = ({
setIsFollowing(following); setIsFollowing(following);
}, [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 ( return (
<Button <Button
className={className} className={clsx(
color='black' className,
disabled={isFollowing} isFollowing && minWidth
label={isFollowing ? 'Following' : 'Follow'} )}
link={type === 'link'} color={isFollowing ? 'outline' : color}
label={isFollowing ? (isHovered ? 'Unfollow' : 'Following') : 'Follow'}
size={size}
onClick={(event) => { onClick={(event) => {
event?.preventDefault(); event?.preventDefault();
event?.stopPropagation(); event?.stopPropagation();
handleClick(); handleClick();
}} }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/> />
); );
}; };

View file

@ -98,7 +98,7 @@ const ActorList: React.FC<ActorListProps> = ({
className='ml-auto' className='ml-auto'
following={isFollowing} following={isFollowing}
handle={getUsername(actor)} handle={getUsername(actor)}
type='link' type='secondary'
/> />
</ActivityItem> </ActivityItem>
{index < actors.length - 1 && <Separator />} {index < actors.length - 1 && <Separator />}
@ -326,6 +326,7 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<FollowButton <FollowButton
following={profile.isFollowing} following={profile.isFollowing}
handle={profile.handle} handle={profile.handle}
type='primary'
onFollow={onFollow} onFollow={onFollow}
onUnfollow={onUnfollow} 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' /> <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 {isOverflowing && <Button
className='absolute bottom-0 text-green' className='absolute bottom-0'
label={isExpanded ? 'Show less' : 'Show all'} label={isExpanded ? 'Show less' : 'Show all'}
link={true} link={true}
size='sm'
onClick={toggleExpand} onClick={toggleExpand}
/>} />}
</div>)} </div>)}

View file

@ -4,6 +4,8 @@ import {
ActivityPubAPI, ActivityPubAPI,
ActivityPubCollectionResponse, ActivityPubCollectionResponse,
ActivityThread, ActivityThread,
Actor,
FollowAccount,
type GetAccountFollowsResponse, type GetAccountFollowsResponse,
type Profile, type Profile,
type SearchResults 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) { export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@ -228,6 +276,8 @@ export function useFollow(handle: string, onSuccess: () => void, onError: () =>
return [followedActor].concat(currentFollowing); return [followedActor].concat(currentFollowing);
}); });
queryClient.invalidateQueries(['follows:index:following']);
queryClient.setQueryData(['followingCount:index'], (currentFollowingCount?: number) => { queryClient.setQueryData(['followingCount:index'], (currentFollowingCount?: number) => {
if (!currentFollowingCount) { if (!currentFollowingCount) {
return 1; return 1;