fix(events): decrement the live like count on un-like

The like count only watched bookmark lists by #a, so when someone removed
an event their new (replaceable) list no longer matched the filter and
never arrived — the count stayed stale until reload. Also watch known
likers by `authors` and track each author's current liked-coords, diffing
prev vs next on every update so a dropped coord decrements live. Verified
end-to-end against a relay: a like incremented the count and the same
key's updated list (coord dropped) decremented it with no reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-17 11:27:16 +02:00
commit 35c62d6ff1

View file

@ -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 * user's NIP-51 bookmark list (kind 10003) the same action the heart
* performs (and what the Favorites page reads). * performs (and what the Favorites page reads).
* *
* One batched subscription covers every event coordinate that a mounted * Counting bookmark lists by `#a` alone can't see un-likes: when someone
* heart has registered, filtered by `#a`. It stays open after EOSE, so * removes an event their new (replaceable) list no longer contains the
* when anyone publishes/updates a bookmark list referencing a tracked * coord, so it stops matching the `#a` filter and never arrives. To make
* event the relay pushes it live and the count increments for everyone * decrements real-time too we ALSO watch every known liker by `authors`,
* in real time (Alice likes Bob's count ticks up). * 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`): * - `#a` filter discovers NEW likers (lists containing a tracked coord).
* - An un-like by ANOTHER user only reflects on next load: their new * - `authors` filter catches updates (add AND remove) from anyone we've
* list no longer contains the coord, so it no longer matches the * already counted, including their last un-like.
* filter and we never receive the update. Counts are correct on a *
* fresh load (the un-liker is simply absent from the results). * Both feed one handler that diffs the author's previous vs current liked
* - The current user's own like/un-like updates instantly via setSelf(), * coords. The current user's own like/un-like also updates instantly via
* driven by the optimistic heart state. * setSelf(), kept consistent with the same per-author bookkeeping.
*/ */
const BOOKMARK_KIND = 10003 const BOOKMARK_KIND = 10003
// coord ("kind:pubkey:dTag") -> set of pubkeys who bookmarked it. const counts = reactive(new Map<string, number>()) // coord -> count (reactive, for the UI)
const authorsByCoord = new Map<string, Set<string>>() // plain map, for dedup const authorsByCoord = new Map<string, Set<string>>() // coord -> pubkeys who like it (count source)
const counts = reactive(new Map<string, number>()) // reactive mirror for the UI const authorCoords = new Map<string, Set<string>>() // pubkey -> tracked coords in their latest list
const authorSeenAt = new Map<string, number>() // pubkey -> created_at of latest processed list
const knownAuthors = new Set<string>() // everyone we've seen like a tracked coord
const tracked = new Set<string>() const tracked = new Set<string>()
let unsubscribe: (() => void) | null = null let unsubscribe: (() => void) | null = null
let resubTimer: ReturnType<typeof setTimeout> | null = null let resubTimer: ReturnType<typeof setTimeout> | null = null
function setCount(coord: string, pubkey: string, present: boolean) { function addAuthor(coord: string, pubkey: string) {
let set = authorsByCoord.get(coord) let set = authorsByCoord.get(coord)
if (!set) { if (!set) {
set = new Set() set = new Set()
authorsByCoord.set(coord, set) authorsByCoord.set(coord, set)
} }
const had = set.has(pubkey) if (!set.has(pubkey)) {
if (present && !had) {
set.add(pubkey) set.add(pubkey)
counts.set(coord, set.size) counts.set(coord, set.size)
} else if (!present && had) {
set.delete(pubkey)
counts.set(coord, set.size)
} }
} }
function ingest(event: NostrEvent) { function removeAuthor(coord: string, pubkey: string) {
// A bookmark list references many events via 'a' tags; credit the const set = authorsByCoord.get(coord)
// author to every coord we're tracking. 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<string>()
for (const tag of event.tags) { 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<string>()
// 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] const coords = [...tracked]
if (coords.length === 0) return if (coords.length === 0) return
const filters: Record<string, unknown>[] = [{ 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({ unsubscribe = relayHub.subscribe({
id: 'event-likes-aggregate', id: 'event-likes-aggregate',
filters: [{ kinds: [BOOKMARK_KIND], '#a': coords }], filters,
onEvent: (event: NostrEvent) => ingest(event), onEvent: (event: NostrEvent) => handleEvent(event),
}) })
} }
function scheduleResubscribe() { function scheduleResubscribe() {
if (resubTimer) clearTimeout(resubTimer) if (resubTimer) clearTimeout(resubTimer)
// Debounced so a burst of mounting hearts results in one (re)subscribe. // Debounced so a burst of mounting hearts / discovered likers results
resubTimer = setTimeout(resubscribe, 250) // in one (re)subscribe.
resubTimer = setTimeout(resubscribe, 300)
} }
export function useEventLikes() { export function useEventLikes() {
@ -96,12 +126,22 @@ export function useEventLikes() {
/** /**
* Reflect the current user's own like state immediately (optimistic), * Reflect the current user's own like state immediately (optimistic),
* so their count matches the instant heart fill and their un-like * kept consistent with the per-author bookkeeping so the real event
* decrements right away. * round-tripping back reconciles to a no-op.
*/ */
function setSelf(coord: string, pubkey: string, liked: boolean) { function setSelf(coord: string, pubkey: string, liked: boolean) {
if (!coord || !pubkey) return if (!coord || !pubkey) return
setCount(coord, pubkey, liked) const set = authorCoords.get(pubkey) ?? new Set<string>()
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 } return { track, likeCount, setSelf }