mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 06:07:29 +02:00
Fix follow/unfollow: fetch kind 3 fresh at write time and cache-write the result
The FollowButton built the replacement kind 3 from the possibly stale/empty cached follow list, then invalidated queries so a lagging relay re-query could race the pre-update (or empty) list back into the cache. Unfollow appeared to do nothing, and a stale empty read could silently wipe the entire list. The toggle now fetches the current kind 3 fresh from relays at click time, throws instead of substituting an empty list on failure, and writes the computed result straight into the query cache (same pattern as useSetPubkeyMuted). Tests for these paths were already failing on main (localStorage undefined in the vitest env); no new failures.
This commit is contained in:
@@ -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 ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||
) : isFollowing ? (
|
||||
<UserMinus className="size-3.5" aria-hidden />
|
||||
|
||||
@@ -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<string[]>({
|
||||
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<typeof useNostr>['nostr'],
|
||||
pubkey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NostrEvent | null> {
|
||||
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<ToggleFollowResult, Error, string>({
|
||||
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<string[]>(followsQueryKey(forPubkey), follows);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user