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:
parent
99799d214c
commit
0cdec925ae
8 changed files with 115 additions and 35 deletions
apps/admin-x-activitypub
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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]}
|
||||||
|
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>)}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Reference in a new issue