Add Follow/Unfollow Button on Profile Page (NIP-02) (#14)

* Initial plan

* Implement Follow/Unfollow button on Profile Page (NIP-02)

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
Copilot
2025-10-06 19:13:53 +02:00
committed by GitHub
parent 50eb5e7e0f
commit 734bd84191
3 changed files with 174 additions and 18 deletions

View File

@@ -0,0 +1,86 @@
import { UserPlus, UserMinus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useToggleFollow } from '@/hooks/useToggleFollow';
import { useFollowing } from '@/hooks/useFollowing';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface FollowButtonProps {
pubkey: string;
className?: string;
variant?: 'default' | 'ghost' | 'outline';
size?: 'default' | 'sm' | 'lg' | 'icon';
showText?: boolean;
}
/**
* Button component to follow/unfollow users using NIP-02 (kind 3 contact list).
* Shows filled icon when following, outline when not.
* Only visible to logged-in users.
*/
export function FollowButton({
pubkey,
className,
variant = 'outline',
size = 'sm',
showText = true,
}: FollowButtonProps) {
const { user } = useCurrentUser();
const { data: followingList = [] } = useFollowing();
const { mutate: toggleFollow, isPending } = useToggleFollow();
const { toast } = useToast();
const isFollowing = followingList.includes(pubkey);
// Don't show button if user is not logged in or viewing their own profile
if (!user || user.pubkey === pubkey) {
return null;
}
const handleClick = () => {
toggleFollow(
{ pubkey },
{
onSuccess: ({ isFollowing: newState }) => {
toast({
title: newState ? 'Following!' : 'Unfollowed',
description: newState
? 'User added to your following list'
: 'User removed from your following list',
});
},
onError: (error) => {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to update following list',
variant: 'destructive',
});
},
}
);
};
return (
<Button
variant={variant}
size={size}
onClick={handleClick}
disabled={isPending}
className={cn(className)}
title={isFollowing ? 'Unfollow this user' : 'Follow this user'}
>
{isFollowing ? (
<>
<UserMinus className="h-4 w-4" />
{showText && <span className="ml-2 hidden sm:inline">Unfollow</span>}
</>
) : (
<>
<UserPlus className="h-4 w-4" />
{showText && <span className="ml-2 hidden sm:inline">Follow</span>}
</>
)}
</Button>
);
}

View File

@@ -0,0 +1,66 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useFollowing } from './useFollowing';
import { useNostrPublish } from './useNostrPublish';
import { useNostr } from '@nostrify/react';
/**
* Hook to toggle following/unfollowing a user.
* Publishes a kind 3 (contact list) event with the updated follows list according to NIP-02.
*/
export function useToggleFollow() {
const { user } = useCurrentUser();
const { data: followingList = [] } = useFollowing();
const { mutateAsync: publishEvent } = useNostrPublish();
const { nostr } = useNostr();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ pubkey }: { pubkey: string }) => {
if (!user) {
throw new Error('Must be logged in to follow/unfollow');
}
// Get the latest kind 3 event to preserve all existing tags
const signal = AbortSignal.timeout(1500);
const events = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal }
);
const existingEvent = events[0];
const existingTags = existingEvent?.tags || [];
// Check if already following
const isFollowing = followingList.includes(pubkey);
let newTags: string[][];
if (isFollowing) {
// Unfollow: remove all p tags with this pubkey
newTags = existingTags.filter(
(tag) => !(tag[0] === 'p' && tag[1] === pubkey)
);
} else {
// Follow: append new p tag with pubkey and empty relay/petname
// Format: ["p", <pubkey>, <relay_url>, <petname>]
newTags = [...existingTags, ['p', pubkey, '', '']];
}
// Publish the updated contact list
const event = await publishEvent({
kind: 3,
content: '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
});
return { event, isFollowing: !isFollowing };
},
onSuccess: () => {
// Invalidate following queries to refetch
queryClient.invalidateQueries({ queryKey: ['following'] });
queryClient.invalidateQueries({ queryKey: ['following-posts'] });
},
});
}

View File

@@ -13,6 +13,7 @@ import { Link2, Mail, Copy, Check, Bookmark } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import { RelaySelector } from '@/components/RelaySelector';
import { ArticlePreview } from '@/components/ArticlePreview';
import { FollowButton } from '@/components/FollowButton';
import { useToast } from '@/hooks/useToast';
import NotFound from '@/pages/NotFound';
import { useState } from 'react';
@@ -159,24 +160,27 @@ export default function ProfilePage() {
<div className="space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl md:text-3xl font-bold">{displayName}</h1>
<Button
variant="outline"
size="sm"
onClick={handleCopyNpub}
className="gap-2"
>
{copied ? (
<>
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Copied</span>
</>
) : (
<>
<Copy className="h-4 w-4" />
<span className="hidden sm:inline">Copy npub</span>
</>
)}
</Button>
<div className="flex items-center gap-2">
<FollowButton pubkey={pubkey} />
<Button
variant="outline"
size="sm"
onClick={handleCopyNpub}
className="gap-2"
>
{copied ? (
<>
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Copied</span>
</>
) : (
<>
<Copy className="h-4 w-4" />
<span className="hidden sm:inline">Copy npub</span>
</>
)}
</Button>
</div>
</div>
{metadata?.name && metadata.name !== displayName && (
<p className="text-muted-foreground">@{userName}</p>