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

Merged
padreug merged 1 commit from fix/events-like-count-unlike into dev 2026-06-17 10:05:08 +00:00

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
* 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<string, Set<string>>() // plain map, for dedup
const counts = reactive(new Map<string, number>()) // reactive mirror for the UI
const counts = reactive(new Map<string, number>()) // coord -> count (reactive, for the UI)
const authorsByCoord = new Map<string, Set<string>>() // coord -> pubkeys who like it (count source)
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>()
let unsubscribe: (() => void) | 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)
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<string>()
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]
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({
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<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 }