Files
eranos/src/hooks/useFollowActions.ts
T
lemon 3bb5f1d32b Add Following feed combining people, communities, and countries
- Split the home feed's old Follows tab into 'Following' (combined) and
  'Network' (people-only, original behavior preserved).
- Add country follows via NIP-51 kind 10015 i tags (iso3166:XX), with
  a Follow/Unfollow button on country pages reusing FollowToggleButton.
- New useFollowingFeed merges network + community activity + followed
  country events, sorted strictly by recency. A recency floor (oldest
  loaded network item, or now-14d when network is empty) prevents
  sparse sources from surfacing old events too early.
- Empty state on Following is country-centric and routes to the World
  tab to encourage country discovery.
- Invalidate the new feed query keys on follow/unfollow and
  community-bookmark mutations.
2026-05-13 17:49:36 -07:00

223 lines
8.3 KiB
TypeScript

import { useCallback, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { useAppContext } from './useAppContext';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { getStorageKey } from '@/lib/storageKey';
import type { NostrEvent } from '@nostrify/nostrify';
// ---------------------------------------------------------------------------
// useFollowList — cached view of the user's follow list for UI reads
// ---------------------------------------------------------------------------
export interface FollowListData {
/** The raw kind 3 event (null if none found). */
event: NostrEvent | null;
/** All pubkeys from `p` tags. */
pubkeys: string[];
}
/** Read cached follow pubkeys from localStorage for a given user. */
function getCachedFollowList(cacheKey: string, pubkey: string): FollowListData | undefined {
try {
const raw = localStorage.getItem(cacheKey);
if (!raw) return undefined;
const cached = JSON.parse(raw);
// Only use cache if it belongs to the same user
if (cached.pubkey !== pubkey || !Array.isArray(cached.pubkeys)) return undefined;
return { event: null, pubkeys: cached.pubkeys };
} catch {
return undefined;
}
}
/** Persist follow pubkeys to localStorage. */
function setCachedFollowList(cacheKey: string, pubkey: string, pubkeys: string[]): void {
try {
localStorage.setItem(cacheKey, JSON.stringify({ pubkey, pubkeys }));
} catch {
// Storage full or unavailable — non-critical
}
}
/**
* Cached hook to read the logged-in user's follow list.
* Use this for **display only** (e.g. checking "is this person followed?").
* For mutations, `useFollowActions` fetches fresh data before writing.
*
* Uses localStorage as a placeholder so the feed query can fire immediately
* on returning visits without waiting for the relay round-trip.
*/
export function useFollowList() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const cacheKey = getStorageKey(config.appId, 'followListCache');
return useQuery<FollowListData>({
queryKey: ['follow-list', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user) return { event: null, pubkeys: [] };
const [event] = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
if (!event) return { event: null, pubkeys: [] };
const pubkeys = event.tags
.filter(([name]) => name === 'p')
.map(([, pk]) => pk);
setCachedFollowList(cacheKey, user.pubkey, pubkeys);
return { event, pubkeys };
},
enabled: !!user,
staleTime: 5 * 60 * 1000,
placeholderData: user ? getCachedFollowList(cacheKey, user.pubkey) : undefined,
});
}
// ---------------------------------------------------------------------------
// useFollowActions — safe follow / unfollow mutation
// ---------------------------------------------------------------------------
export interface UseFollowActionsReturn {
/** Whether a follow/unfollow mutation is in progress. */
isPending: boolean;
/** Follow a pubkey. Fetches the freshest kind 3 first, then publishes. */
follow: (pubkey: string) => Promise<void>;
/** Unfollow a pubkey. Fetches the freshest kind 3 first, then publishes. */
unfollow: (pubkey: string) => Promise<void>;
/**
* Follow many pubkeys in a single kind 3 publish. Fetches the freshest kind 3
* first, merges in any pubkeys not already followed, and preserves all
* existing tags + content. Returns the count of pubkeys newly added.
*/
followMany: (pubkeys: string[]) => Promise<number>;
}
/**
* Safe follow / unfollow actions modelled after the follow-party project.
*
* Key safety properties:
* 1. Fetches the freshest kind 3 event from multiple relays **right before** mutating.
* 2. Picks the event with the highest `created_at` across all relay responses.
* 3. Preserves **all** existing tags (not just `p` tags) so non-follow metadata is not lost.
* 4. Preserves the `content` field (some clients store relay hints there).
*/
export function useFollowActions(): UseFollowActionsReturn {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const [isPending, setIsPending] = useState(false);
const mutateFollowList = useCallback(
async (targetPubkey: string, action: 'follow' | 'unfollow') => {
if (!user) throw new Error('Not logged in');
setIsPending(true);
try {
// ① Fetch the freshest kind 3 event via pool
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ② Separate tags into `p` tags (follow entries) and everything else
const existingTags = prev?.tags ?? [];
const pTags = existingTags.filter(([name]) => name === 'p');
const nonPTags = existingTags.filter(([name]) => name !== 'p');
// ③ Compute the new set of `p` tags
let newPTags: string[][];
if (action === 'follow') {
// Add only if not already present (dedup)
const alreadyFollowed = pTags.some(([, pk]) => pk === targetPubkey);
newPTags = alreadyFollowed ? pTags : [...pTags, ['p', targetPubkey]];
} else {
// Remove the target pubkey
newPTags = pTags.filter(([, pk]) => pk !== targetPubkey);
}
// ④ Rebuild the full tag array: non-p tags first, then p tags
const newTags = [...nonPTags, ...newPTags];
// ⑤ Preserve the content field (relay hints / petnames in some clients)
const content = prev?.content ?? '';
await publishEvent({
kind: 3,
content,
tags: newTags,
prev: prev ?? undefined,
});
// ⑥ Invalidate cached follow-list queries so UI updates
queryClient.invalidateQueries({ queryKey: ['follow-list'] });
queryClient.invalidateQueries({ queryKey: ['feed'] });
queryClient.invalidateQueries({ queryKey: ['following-feed'] });
} finally {
setIsPending(false);
}
},
[nostr, user, publishEvent, queryClient],
);
const follow = useCallback(
(pubkey: string) => mutateFollowList(pubkey, 'follow'),
[mutateFollowList],
);
const unfollow = useCallback(
(pubkey: string) => mutateFollowList(pubkey, 'unfollow'),
[mutateFollowList],
);
const followMany = useCallback(
async (pubkeys: string[]): Promise<number> => {
if (!user) throw new Error('Not logged in');
setIsPending(true);
try {
// ① Fetch the freshest kind 3 event via pool
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ② Separate p-tags from everything else (preserve relay hints, petnames, etc.)
const existingTags = prev?.tags ?? [];
const existingPTags = existingTags.filter(([name]) => name === 'p');
const nonPTags = existingTags.filter(([name]) => name !== 'p');
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
// ③ Compute the additions: dedupe input + filter out already-followed + skip self
const seen = new Set<string>();
const newPTags: string[][] = [];
for (const pk of pubkeys) {
if (!pk || pk === user.pubkey || existingPubkeys.has(pk) || seen.has(pk)) continue;
seen.add(pk);
newPTags.push(['p', pk]);
}
// Nothing to add — skip the publish to avoid a no-op kind 3 broadcast
if (newPTags.length === 0) return 0;
// ④ Publish (non-p tags first, then existing p tags, then new p tags)
await publishEvent({
kind: 3,
content: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
});
// ⑤ Invalidate cached follow-list queries so UI updates
queryClient.invalidateQueries({ queryKey: ['follow-list'] });
return newPTags.length;
} finally {
setIsPending(false);
}
},
[nostr, user, publishEvent, queryClient],
);
return { isPending, follow, unfollow, followMany };
}