diff --git a/src/modules/events/composables/useEventLikes.ts b/src/modules/events/composables/useEventLikes.ts index 5a222f0..9bca9fd 100644 --- a/src/modules/events/composables/useEventLikes.ts +++ b/src/modules/events/composables/useEventLikes.ts @@ -7,77 +7,52 @@ import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' * user's NIP-51 bookmark list (kind 10003) — the same action the heart * performs (and what the Favorites page reads). * - * Counting bookmark lists by `#a` alone can't see un-likes: when someone - * removes an event their new (replaceable) list no longer contains the - * coord, so it stops matching the `#a` filter and never arrives. To make - * decrements real-time too we ALSO watch every known liker by `authors`, - * and track each author's current set of liked coords — so a list that - * drops a coord is delivered and we remove that author from the count. + * One batched subscription covers every event coordinate that a mounted + * heart has registered, filtered by `#a`. It stays open after EOSE, so + * when anyone publishes/updates a bookmark list referencing a tracked + * event the relay pushes it live and the count increments for everyone + * in real time (Alice likes → Bob's count ticks up). * - * - `#a` filter → discovers NEW likers (lists containing a tracked coord). - * - `authors` filter → catches updates (add AND remove) from anyone we've - * already counted, including their last un-like. - * - * Both feed one handler that diffs the author's previous vs current liked - * coords. The current user's own like/un-like also updates instantly via - * setSelf(), kept consistent with the same per-author bookkeeping. + * Caveats (inherent to counting replaceable bookmark lists via `#a`): + * - An un-like by ANOTHER user only reflects on next load: their new + * list no longer contains the coord, so it no longer matches the + * filter and we never receive the update. Counts are correct on a + * fresh load (the un-liker is simply absent from the results). + * - The current user's own like/un-like updates instantly via setSelf(), + * driven by the optimistic heart state. */ const BOOKMARK_KIND = 10003 -const counts = reactive(new Map()) // coord -> count (reactive, for the UI) -const authorsByCoord = new Map>() // coord -> pubkeys who like it (count source) -const authorCoords = new Map>() // pubkey -> tracked coords in their latest list -const authorSeenAt = new Map() // pubkey -> created_at of latest processed list -const knownAuthors = new Set() // everyone we've seen like a tracked coord +// coord ("kind:pubkey:dTag") -> set of pubkeys who bookmarked it. +const authorsByCoord = new Map>() // plain map, for dedup +const counts = reactive(new Map()) // reactive mirror for the UI const tracked = new Set() let unsubscribe: (() => void) | null = null let resubTimer: ReturnType | null = null -function addAuthor(coord: string, pubkey: string) { +function setCount(coord: string, pubkey: string, present: boolean) { let set = authorsByCoord.get(coord) if (!set) { set = new Set() authorsByCoord.set(coord, set) } - if (!set.has(pubkey)) { + const had = set.has(pubkey) + if (present && !had) { set.add(pubkey) counts.set(coord, set.size) + } else if (!present && had) { + set.delete(pubkey) + counts.set(coord, set.size) } } -function removeAuthor(coord: string, pubkey: string) { - const set = authorsByCoord.get(coord) - if (set && set.delete(pubkey)) counts.set(coord, set.size) -} - -function handleEvent(event: NostrEvent) { - const pk = event.pubkey - const seenAt = authorSeenAt.get(pk) ?? -1 - // Ignore strictly-older replaceable lists; reprocess same/newer (so a - // re-delivery after we start tracking a new coord still credits it). - if (event.created_at < seenAt) return - authorSeenAt.set(pk, Math.max(seenAt, event.created_at)) - - // Tracked coords this list currently references. - const next = new Set() +function ingest(event: NostrEvent) { + // A bookmark list references many events via 'a' tags; credit the + // author to every coord we're tracking. for (const tag of event.tags) { - if (tag[0] === 'a' && tracked.has(tag[1])) next.add(tag[1]) - } - const prev = authorCoords.get(pk) ?? new Set() - - // Removals (un-likes): present before, gone now. - for (const coord of prev) if (!next.has(coord)) removeAuthor(coord, pk) - // Additions (likes): new this time. - for (const coord of next) if (!prev.has(coord)) addAuthor(coord, pk) - authorCoords.set(pk, next) - - // Watch this liker by author from now on so their future un-like (which - // wouldn't match the #a filter) still reaches us. - if (next.size && !knownAuthors.has(pk)) { - knownAuthors.add(pk) - scheduleResubscribe() + if (tag[0] === 'a' && tracked.has(tag[1])) setCount(tag[1], event.pubkey, true) } } @@ -93,22 +68,17 @@ function resubscribe() { } const coords = [...tracked] if (coords.length === 0) return - const filters: Record[] = [{ kinds: [BOOKMARK_KIND], '#a': coords }] - // Also watch known likers by author, so an un-like (a list that drops - // the coord, no longer matching #a) is still delivered and decrements. - if (knownAuthors.size) filters.push({ kinds: [BOOKMARK_KIND], authors: [...knownAuthors] }) unsubscribe = relayHub.subscribe({ id: 'event-likes-aggregate', - filters, - onEvent: (event: NostrEvent) => handleEvent(event), + filters: [{ kinds: [BOOKMARK_KIND], '#a': coords }], + onEvent: (event: NostrEvent) => ingest(event), }) } function scheduleResubscribe() { if (resubTimer) clearTimeout(resubTimer) - // Debounced so a burst of mounting hearts / discovered likers results - // in one (re)subscribe. - resubTimer = setTimeout(resubscribe, 300) + // Debounced so a burst of mounting hearts results in one (re)subscribe. + resubTimer = setTimeout(resubscribe, 250) } export function useEventLikes() { @@ -126,22 +96,12 @@ export function useEventLikes() { /** * Reflect the current user's own like state immediately (optimistic), - * kept consistent with the per-author bookkeeping so the real event - * round-tripping back reconciles to a no-op. + * so their count matches the instant heart fill and their un-like + * decrements right away. */ function setSelf(coord: string, pubkey: string, liked: boolean) { if (!coord || !pubkey) return - const set = authorCoords.get(pubkey) ?? new Set() - const had = set.has(coord) - if (liked && !had) { - set.add(coord) - authorCoords.set(pubkey, set) - addAuthor(coord, pubkey) - } else if (!liked && had) { - set.delete(coord) - authorCoords.set(pubkey, set) - removeAuthor(coord, pubkey) - } + setCount(coord, pubkey, liked) } return { track, likeCount, setSelf }