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);
+ },
+ });
+}