Grouped emoji reactions, npub search, notification fix, dev logger

- Emoji reactions now display as grouped pills (❤️5 🤙3 🔥2) instead
  of a single aggregated count. Multi-reaction per note supported.
- Throttled reaction fetch queue (max 4 concurrent) prevents relay overload.
- Searching a bare npub/nprofile navigates directly to that profile.
- Notification poller waits for relay connection before first fetch,
  fixing empty results on startup.
- Dev-only debug logger (src/lib/debug.ts) — silent in production builds.
This commit is contained in:
Jure
2026-03-27 18:20:00 +01:00
parent fe2740c00e
commit b46d383200
9 changed files with 266 additions and 58 deletions

89
src/hooks/useReactions.ts Normal file
View File

@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "react";
import { fetchReactions } from "../lib/nostr";
import type { GroupedReactions } from "../lib/nostr";
import { useUserStore } from "../stores/user";
const cache = new Map<string, GroupedReactions>();
// Queue to throttle parallel relay queries — too many at once causes timeouts
const pending = new Map<string, Promise<GroupedReactions>>();
let activeCount = 0;
const MAX_CONCURRENT = 4;
const queue: Array<() => void> = [];
function runNext() {
if (queue.length > 0 && activeCount < MAX_CONCURRENT) {
const next = queue.shift()!;
next();
}
}
function throttledFetch(eventId: string, pubkey?: string): Promise<GroupedReactions> {
if (pending.has(eventId)) return pending.get(eventId)!;
const promise = new Promise<GroupedReactions>((resolve) => {
const doFetch = () => {
activeCount++;
fetchReactions(eventId, pubkey).then((result) => {
activeCount--;
pending.delete(eventId);
resolve(result);
runNext();
});
};
if (activeCount < MAX_CONCURRENT) {
doFetch();
} else {
queue.push(doFetch);
}
});
pending.set(eventId, promise);
return promise;
}
export function useReactions(eventId: string): [GroupedReactions | null, (emoji: string) => void] {
const [data, setData] = useState<GroupedReactions | null>(() => cache.get(eventId) ?? null);
const pubkeyRef = useRef(useUserStore.getState().pubkey);
useEffect(() => {
pubkeyRef.current = useUserStore.getState().pubkey;
});
useEffect(() => {
if (cache.has(eventId)) {
setData(cache.get(eventId)!);
return;
}
let cancelled = false;
throttledFetch(eventId, pubkeyRef.current ?? undefined).then((result) => {
if (!cancelled) {
cache.set(eventId, result);
setData(result);
}
});
return () => { cancelled = true; };
}, [eventId]);
const addReaction = (emoji: string) => {
setData((prev) => {
const groups = new Map(prev?.groups ?? []);
groups.set(emoji, (groups.get(emoji) ?? 0) + 1);
const myReactions = new Set(prev?.myReactions ?? []);
myReactions.add(emoji);
const total = (prev?.total ?? 0) + 1;
const next: GroupedReactions = { groups, myReactions, total };
cache.set(eventId, next);
return next;
});
};
return [data, addReaction];
}
/** Seed the cache from batch engagement data (avoids per-note refetching). */
export function seedReactionsCache(eventId: string, groups: Map<string, number>, myReactions: Set<string>) {
const total = Array.from(groups.values()).reduce((sum, n) => sum + n, 0);
cache.set(eventId, { groups, myReactions, total });
}