mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-05 10:01:22 +02:00
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:
86
src/components/FollowButton.tsx
Normal file
86
src/components/FollowButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/hooks/useToggleFollow.ts
Normal file
66
src/hooks/useToggleFollow.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user