diff --git a/src/apps/profile/index.tsx b/src/apps/profile/index.tsx index b2ef435..78a0f83 100644 --- a/src/apps/profile/index.tsx +++ b/src/apps/profile/index.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { useNostr } from '@nostrify/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Check, Copy, Globe, Loader2, UserMinus, UserPlus } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome'; @@ -12,8 +12,7 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useMyFollows } from '@/hooks/useFollows'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useMyFollows, useToggleFollow } from '@/hooks/useFollows'; import { useToast } from '@/hooks/useToast'; import { decodeRelayHints, displayName, isReply, npubOf, sanitizeUrl } from '@/lib/nostrUtils'; import type { AppProps } from '@/os/types'; @@ -167,32 +166,23 @@ function CopyNpubButton({ pubkey }: { pubkey: string }) { } /** - * Follows are a whole-list replacement (kind 3), so the current list has to be - * read back before writing or the edit would silently drop everyone else. + * Follows are a whole-list replacement (kind 3). The toggle itself lives in + * `useToggleFollow`; this button only reflects the cached list and toasts. */ function FollowButton({ pubkey }: { pubkey: string }) { const { user } = useCurrentUser(); - const myFollows = useMyFollows(); - const publish = useNostrPublish(); - const queryClient = useQueryClient(); + const { data: follows } = useMyFollows(); + const toggleFollow = useToggleFollow(); const { toast } = useToast(); - const follows = useMemo(() => myFollows.data ?? [], [myFollows.data]); - const isFollowing = follows.includes(pubkey); + const isFollowing = (follows ?? []).includes(pubkey); const isSelf = user?.pubkey === pubkey; if (!user || isSelf) return null; const toggle = async () => { - const next = isFollowing ? follows.filter((key) => key !== pubkey) : [...follows, pubkey]; - try { - await publish.mutateAsync({ - kind: 3, - content: '', - tags: next.map((key) => ['p', key]), - }); - await queryClient.invalidateQueries({ queryKey: ['nostr', 'follows'] }); + await toggleFollow.mutateAsync(pubkey); toast({ title: isFollowing ? 'Unfollowed' : 'Following' }); } catch (error) { toast({ @@ -209,9 +199,9 @@ function FollowButton({ pubkey }: { pubkey: string }) { variant={isFollowing ? 'outline' : 'default'} className="h-7 gap-1.5 px-2.5 text-xs" onClick={toggle} - disabled={publish.isPending || myFollows.isLoading} + disabled={toggleFollow.isPending} > - {publish.isPending ? ( + {toggleFollow.isPending ? ( ) : isFollowing ? ( diff --git a/src/hooks/useFollows.ts b/src/hooks/useFollows.ts index 838a55a..5f3e0c5 100644 --- a/src/hooks/useFollows.ts +++ b/src/hooks/useFollows.ts @@ -1,29 +1,19 @@ import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; /** The pubkeys in a user's kind 3 contact list. */ export function useFollows(pubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ - queryKey: ['nostr', 'follows', pubkey ?? ''], + queryKey: followsQueryKey(pubkey), enabled: Boolean(pubkey), queryFn: async ({ signal }) => { - const [event] = await nostr.query( - [{ kinds: [3], authors: [pubkey!], limit: 1 }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) }, - ); - - if (!event) return []; - - return [ - ...new Set( - event.tags - .filter(([name, value]) => name === 'p' && typeof value === 'string') - .map(([, value]) => value), - ), - ]; + const event = await fetchFollowEvent(nostr, pubkey!, signal); + return extractFollows(event?.tags ?? []); }, staleTime: 5 * 60 * 1000, }); @@ -34,3 +24,90 @@ export function useMyFollows() { const { user } = useCurrentUser(); return useFollows(user?.pubkey); } + +function followsQueryKey(pubkey: string | undefined) { + return ['nostr', 'follows', pubkey ?? ''] as const; +} + +/** + * Reads the user's current kind 3 event. Throws on timeout instead of + * resolving to an empty list — a failed read must never be mistaken for + * "follows nobody", because the caller is about to replace the whole list + * and an empty stand-in would silently wipe every real entry. + */ +async function fetchFollowEvent( + nostr: ReturnType['nostr'], + pubkey: string, + signal?: AbortSignal, +): Promise { + const timeout = AbortSignal.timeout(6000); + const events = await nostr.query( + [{ kinds: [3], authors: [pubkey], limit: 1 }], + { signal: AbortSignal.any([timeout, ...(signal ? [signal] : [])]) }, + ); + if (timeout.aborted || signal?.aborted) { + throw new Error('Could not read your follow list from your relays. Try again.'); + } + return events[0] ?? null; +} + +function extractFollows(tags: string[][]): string[] { + return [ + ...new Set( + tags + .filter(([name, value]) => name === 'p' && typeof value === 'string') + .map(([, value]) => value), + ), + ]; +} + +interface ToggleFollowResult { + forPubkey: string; + follows: string[]; +} + +/** + * Follows are a whole-list replacement (kind 3), so the current list has to be + * read back before writing or the edit would silently drop everyone else. + * + * The list is fetched fresh from the relays at click time — not read from the + * query cache — and the computed result is written straight into the cache on + * success. Deliberately no `invalidateQueries` here: right after publishing, + * a re-query can still race back the pre-update list from a lagging relay and + * silently clobber this correct value (the same eventual-consistency race + * `useSetPubkeyMuted` documents). That race is what made unfollow appear to + * do nothing and, when the stale read came back empty, wiped whole lists. + */ +export function useToggleFollow() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const publish = useNostrPublish(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (target) => { + if (!user) throw new Error('Sign in to follow accounts'); + + const current = await fetchFollowEvent(nostr, user.pubkey); + const currentFollows = extractFollows(current?.tags ?? []); + const isFollowing = currentFollows.includes(target); + + const follows = isFollowing + ? currentFollows.filter((key) => key !== target) + : [...currentFollows, target]; + + await publish.mutateAsync({ + kind: 3, + content: current?.content ?? '', + tags: follows.map((key) => ['p', key]), + }); + + return { forPubkey: user.pubkey, follows }; + }, + // `forPubkey` is captured by mutationFn at call time so a stale callback + // can never write into another account's (or a logged-out) query key. + onSuccess: ({ forPubkey, follows }) => { + queryClient.setQueryData(followsQueryKey(forPubkey), follows); + }, + }); +}