diff --git a/js/reactionCounter.js b/js/reactionCounter.js index 0e2b9a51..0dfed685 100644 --- a/js/reactionCounter.js +++ b/js/reactionCounter.js @@ -1,6 +1,241 @@ import { publishVideoReaction } from "./nostr.js"; +import { normalizePointerInput, pointerKey } from "./nostr/watchHistory.js"; import { devLogger, userLogger } from "./utils/logger.js"; +const pointerStates = new Map(); +const pointerHandlers = new Map(); + +function clonePointerDescriptor(pointer) { + if (!pointer || typeof pointer !== "object") { + return null; + } + const descriptor = { + type: pointer.type === "a" ? "a" : "e", + value: + typeof pointer.value === "string" && pointer.value.trim() + ? pointer.value.trim() + : "", + }; + if (typeof pointer.relay === "string" && pointer.relay.trim()) { + descriptor.relay = pointer.relay.trim(); + } + return descriptor; +} + +function canonicalizePointerForState(pointerInput) { + const normalized = normalizePointerInput(pointerInput); + if (!normalized) { + return null; + } + const key = pointerKey(normalized); + if (!key) { + return null; + } + const descriptor = clonePointerDescriptor(normalized); + return descriptor ? { key, pointer: descriptor } : null; +} + +function ensurePointerState(key, pointer) { + let state = pointerStates.get(key); + if (!state) { + state = { + pointer: pointer ? clonePointerDescriptor(pointer) : null, + totals: new Map(), + reactionsByPubkey: new Map(), + lastUpdatedAt: 0, + }; + pointerStates.set(key, state); + return state; + } + if (pointer) { + const descriptor = clonePointerDescriptor(pointer); + if (!state.pointer) { + state.pointer = descriptor; + } else if (descriptor?.relay && !state.pointer.relay) { + state.pointer = { ...state.pointer, relay: descriptor.relay }; + } + } + return state; +} + +function normalizeReactionEvent(event) { + if (!event || typeof event !== "object") { + return null; + } + const pubkey = + typeof event.pubkey === "string" && event.pubkey.trim() + ? event.pubkey.trim().toLowerCase() + : ""; + if (!pubkey) { + return null; + } + let content = "+"; + if (typeof event.content === "string") { + content = event.content; + } else if (event.content !== undefined && event.content !== null) { + try { + content = String(event.content); + } catch (error) { + content = "+"; + } + } + if (!content) { + content = "+"; + } + const createdAt = Number.isFinite(event.created_at) + ? Math.max(0, Math.floor(event.created_at)) + : 0; + const eventId = + typeof event.id === "string" && event.id.trim() ? event.id.trim() : ""; + return { pubkey, content, created_at: createdAt, eventId }; +} + +function incrementContentCount(state, content) { + if (typeof content !== "string") { + return; + } + const existing = state.totals.get(content) || 0; + state.totals.set(content, existing + 1); +} + +function decrementContentCount(state, content) { + if (typeof content !== "string") { + return; + } + const existing = state.totals.get(content); + if (!existing) { + return; + } + if (existing <= 1) { + state.totals.delete(content); + } else { + state.totals.set(content, existing - 1); + } +} + +function applyReactionToState(key, pointer, event) { + const normalized = normalizeReactionEvent(event); + if (!normalized) { + return false; + } + const state = ensurePointerState(key, pointer); + const existing = state.reactionsByPubkey.get(normalized.pubkey); + + if (existing) { + if (existing.created_at > normalized.created_at) { + return false; + } + if ( + existing.created_at === normalized.created_at && + existing.eventId && + normalized.eventId && + existing.eventId === normalized.eventId && + existing.content === normalized.content + ) { + return false; + } + if ( + existing.created_at === normalized.created_at && + existing.eventId && + !normalized.eventId && + existing.content === normalized.content + ) { + return false; + } + } + + let changed = false; + if (!existing) { + incrementContentCount(state, normalized.content); + changed = true; + } else { + if (existing.content !== normalized.content) { + decrementContentCount(state, existing.content); + incrementContentCount(state, normalized.content); + changed = true; + } + } + + if ( + !existing || + existing.created_at !== normalized.created_at || + existing.eventId !== normalized.eventId || + existing.content !== normalized.content + ) { + state.reactionsByPubkey.set(normalized.pubkey, normalized); + state.lastUpdatedAt = Date.now(); + if (!changed) { + changed = true; + } + } + + return changed; +} + +function snapshotState(state) { + const counts = {}; + let total = 0; + for (const [content, count] of state.totals.entries()) { + counts[content] = count; + total += count; + } + const reactions = {}; + for (const [pubkey, record] of state.reactionsByPubkey.entries()) { + reactions[pubkey] = { + content: record.content, + created_at: record.created_at, + eventId: record.eventId, + }; + } + return { + pointer: state.pointer ? { ...state.pointer } : null, + total, + counts, + reactions, + lastUpdatedAt: state.lastUpdatedAt, + }; +} + +function notifyHandlers(key) { + const handlers = pointerHandlers.get(key); + if (!handlers || !handlers.size) { + return; + } + const state = pointerStates.get(key); + if (!state) { + return; + } + const snapshot = snapshotState(state); + for (const handler of handlers) { + if (typeof handler !== "function") { + continue; + } + try { + handler(snapshot); + } catch (error) { + userLogger.warn("[reactionCounter] Reaction handler threw:", error); + } + } +} + +export function ingestLocalReaction({ event, pointer }) { + if (!event || !pointer) { + return; + } + try { + const canonical = canonicalizePointerForState(pointer); + if (!canonical) { + return; + } + const changed = applyReactionToState(canonical.key, canonical.pointer, event); + if (changed) { + notifyHandlers(canonical.key); + } + } catch (error) { + userLogger.warn("[reactionCounter] Failed to ingest local reaction:", error); + } +} + export const reactionCounter = { async publish(pointer, options = {}) { try { @@ -94,6 +329,20 @@ export const reactionCounter = { `[reactionCounter] Reaction accepted by ${result.acceptedRelays.length} relay(s):`, result.acceptedRelays.join(", ") ); + const eventPayload = result.event || { + pubkey: + typeof publishOptions.actorPubkey === "string" + ? publishOptions.actorPubkey + : undefined, + content: publishOptions.content, + created_at: publishOptions.created_at, + }; + if (eventPayload && eventPayload.pubkey) { + ingestLocalReaction({ + pointer: enrichedPointer, + event: eventPayload, + }); + } } return result; } catch (error) { @@ -103,4 +352,6 @@ export const reactionCounter = { }, }; +reactionCounter.ingestLocalReaction = ingestLocalReaction; + export default reactionCounter;