From 734bd84191f2fff1c101594ffb4782a0208a7660 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:13:53 +0200 Subject: [PATCH] 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> --- src/components/FollowButton.tsx | 86 +++++++++++++++++++++++++++++++++ src/hooks/useToggleFollow.ts | 66 +++++++++++++++++++++++++ src/pages/ProfilePage.tsx | 40 ++++++++------- 3 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 src/components/FollowButton.tsx create mode 100644 src/hooks/useToggleFollow.ts diff --git a/src/components/FollowButton.tsx b/src/components/FollowButton.tsx new file mode 100644 index 0000000..f095165 --- /dev/null +++ b/src/components/FollowButton.tsx @@ -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 ( + + ); +} diff --git a/src/hooks/useToggleFollow.ts b/src/hooks/useToggleFollow.ts new file mode 100644 index 0000000..c54f09d --- /dev/null +++ b/src/hooks/useToggleFollow.ts @@ -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", , , ] + 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'] }); + }, + }); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index cd69d59..e997105 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -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() {

{displayName}

- +
+ + +
{metadata?.name && metadata.name !== displayName && (

@{userName}