diff --git a/src/modules/events/composables/useEventLikes.ts b/src/modules/events/composables/useEventLikes.ts index 9bca9fd..5a222f0 100644 --- a/src/modules/events/composables/useEventLikes.ts +++ b/src/modules/events/composables/useEventLikes.ts @@ -7,52 +7,77 @@ 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). * - * 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). + * 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. * - * 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. + * - `#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. */ const BOOKMARK_KIND = 10003 -// 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 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 const tracked = new Set() let unsubscribe: (() => void) | null = null let resubTimer: ReturnType | null = null -function setCount(coord: string, pubkey: string, present: boolean) { +function addAuthor(coord: string, pubkey: string) { let set = authorsByCoord.get(coord) if (!set) { set = new Set() authorsByCoord.set(coord, set) } - const had = set.has(pubkey) - if (present && !had) { + if (!set.has(pubkey)) { set.add(pubkey) counts.set(coord, set.size) - } else if (!present && had) { - set.delete(pubkey) - counts.set(coord, set.size) } } -function ingest(event: NostrEvent) { - // A bookmark list references many events via 'a' tags; credit the - // author to every coord we're tracking. +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() for (const tag of event.tags) { - if (tag[0] === 'a' && tracked.has(tag[1])) setCount(tag[1], event.pubkey, true) + 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() } } @@ -68,17 +93,22 @@ 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: [{ kinds: [BOOKMARK_KIND], '#a': coords }], - onEvent: (event: NostrEvent) => ingest(event), + filters, + onEvent: (event: NostrEvent) => handleEvent(event), }) } function scheduleResubscribe() { if (resubTimer) clearTimeout(resubTimer) - // Debounced so a burst of mounting hearts results in one (re)subscribe. - resubTimer = setTimeout(resubscribe, 250) + // Debounced so a burst of mounting hearts / discovered likers results + // in one (re)subscribe. + resubTimer = setTimeout(resubscribe, 300) } export function useEventLikes() { @@ -96,12 +126,22 @@ export function useEventLikes() { /** * Reflect the current user's own like state immediately (optimistic), - * so their count matches the instant heart fill and their un-like - * decrements right away. + * kept consistent with the per-author bookkeeping so the real event + * round-tripping back reconciles to a no-op. */ function setSelf(coord: string, pubkey: string, liked: boolean) { if (!coord || !pubkey) return - setCount(coord, pubkey, liked) + 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) + } } return { track, likeCount, setSelf }