Merge pull request 'fix(events): decrement the live like count on un-like' (#112) from fix/events-like-count-unlike into dev
Reviewed-on: #112
This commit is contained in:
commit
8f85a5819b
1 changed files with 72 additions and 32 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue