diff --git a/js/reactionCounter.js b/js/reactionCounter.js index c5a2acd2..0cb69e52 100644 --- a/js/reactionCounter.js +++ b/js/reactionCounter.js @@ -19,11 +19,21 @@ function clonePointerDescriptor(pointer) { value: typeof pointer.value === "string" && pointer.value.trim() ? pointer.value.trim() - : "", + : "" }; if (typeof pointer.relay === "string" && pointer.relay.trim()) { descriptor.relay = pointer.relay.trim(); } + const eventId = + typeof pointer.eventId === "string" && pointer.eventId.trim() + ? pointer.eventId.trim() + : typeof pointer.pointerEventId === "string" && + pointer.pointerEventId.trim() + ? pointer.pointerEventId.trim() + : ""; + if (eventId) { + descriptor.eventId = eventId; + } return descriptor; } @@ -36,7 +46,16 @@ function canonicalizePointerForState(pointerInput) { if (!key) { return null; } - const descriptor = clonePointerDescriptor(normalized); + const descriptor = clonePointerDescriptor({ + ...normalized, + eventId: + typeof pointerInput?.eventId === "string" && pointerInput.eventId.trim() + ? pointerInput.eventId + : typeof pointerInput?.pointerEventId === "string" && + pointerInput.pointerEventId.trim() + ? pointerInput.pointerEventId + : "" + }); return descriptor ? { key, pointer: descriptor } : null; } @@ -50,7 +69,7 @@ function ensurePointerState(key, pointer) { lastUpdatedAt: 0, latestCreatedAt: 0, lastSyncedAt: 0, - hydrated: false, + hydrated: false }; pointerStates.set(key, state); return state; @@ -59,8 +78,16 @@ function ensurePointerState(key, 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 }; + } else { + state.pointer = { + ...state.pointer, + ...(descriptor?.relay && !state.pointer.relay + ? { relay: descriptor.relay } + : {}), + ...(descriptor?.eventId && !state.pointer.eventId + ? { eventId: descriptor.eventId } + : {}) + }; } } return state; @@ -174,7 +201,10 @@ function applyReactionToState(key, pointer, event) { state.lastUpdatedAt = Date.now(); if (Number.isFinite(normalized.created_at)) { const created = Math.max(0, Math.floor(normalized.created_at)); - if (!Number.isFinite(state.latestCreatedAt) || created > state.latestCreatedAt) { + if ( + !Number.isFinite(state.latestCreatedAt) || + created > state.latestCreatedAt + ) { state.latestCreatedAt = created; } } @@ -198,7 +228,7 @@ function snapshotState(state) { reactions[pubkey] = { content: record.content, created_at: record.created_at, - eventId: record.eventId, + eventId: record.eventId }; } const latestCreatedAt = Number.isFinite(state.latestCreatedAt) @@ -212,7 +242,7 @@ function snapshotState(state) { lastUpdatedAt: state.lastUpdatedAt, latestCreatedAt, lastSyncedAt: state.lastSyncedAt, - hydrated: Boolean(state.hydrated), + hydrated: Boolean(state.hydrated) }; } @@ -271,7 +301,7 @@ function subscribeToPointer(pointerInput, handler) { .catch((error) => { devLogger.warn( "[reactionCounter] Failed to hydrate reaction history:", - error, + error ); }) .finally(() => { @@ -299,7 +329,7 @@ function subscribeToPointer(pointerInput, handler) { .catch((error) => { devLogger.warn( "[reactionCounter] Failed to hydrate reaction history after pool init:", - error, + error ); }) .finally(() => { @@ -314,7 +344,7 @@ function subscribeToPointer(pointerInput, handler) { .catch((error) => { devLogger.warn( "[reactionCounter] Failed to initialize nostr pool for reactions:", - error, + error ); }); } @@ -372,12 +402,19 @@ function ingestLocalReaction({ event, pointer }) { if (!canonical) { return; } - const changed = applyReactionToState(canonical.key, canonical.pointer, event); + const changed = applyReactionToState( + canonical.key, + canonical.pointer, + event + ); if (changed) { notifyHandlers(canonical.key); } } catch (error) { - userLogger.warn("[reactionCounter] Failed to ingest local reaction:", error); + userLogger.warn( + "[reactionCounter] Failed to ingest local reaction:", + error + ); } } @@ -446,7 +483,7 @@ function gatherRelayCandidates(pointer) { return relays; } -function buildReactionFilters(pointer, { limit, since } = {}) { +export function buildReactionFilters(pointer, { limit, since } = {}) { if (!pointer || typeof pointer !== "object") { return []; } @@ -460,23 +497,29 @@ function buildReactionFilters(pointer, { limit, since } = {}) { return []; } - const filter = { kinds: [REACTION_EVENT_KIND] }; + const withBounds = (filter) => { + if (Number.isFinite(limit) && limit > 0) { + filter.limit = Math.max(1, Math.floor(limit)); + } + if (Number.isFinite(since) && since >= 0) { + filter.since = Math.max(0, Math.floor(since)); + } + return filter; + }; - if (type === "a") { - filter["#a"] = [value]; - } else { - filter["#e"] = [value]; + if (type === "e") { + return [withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [value] })]; } - if (Number.isFinite(limit) && limit > 0) { - filter.limit = Math.max(1, Math.floor(limit)); + const filters = [withBounds({ kinds: [REACTION_EVENT_KIND], "#a": [value] })]; + const eventId = + typeof pointer.eventId === "string" && pointer.eventId.trim() + ? pointer.eventId.trim() + : ""; + if (eventId) { + filters.push(withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [eventId] })); } - - if (Number.isFinite(since) && since >= 0) { - filter.since = Math.max(0, Math.floor(since)); - } - - return [filter]; + return filters; } function buildPointerQuery(pointer, options = {}) { @@ -556,7 +599,10 @@ function ensureReactionHydration(key, pointer, state) { const promise = hydratePointerState(key, pointer, state) .catch((error) => { - devLogger.warn("[reactionCounter] Failed to hydrate reaction history:", error); + devLogger.warn( + "[reactionCounter] Failed to hydrate reaction history:", + error + ); }) .finally(() => { pointerHydrationPromises.delete(key); @@ -577,7 +623,10 @@ function teardownLiveSubscription(key) { try { unsub(); } catch (error) { - devLogger.warn("[reactionCounter] Failed to tear down reaction stream:", error); + devLogger.warn( + "[reactionCounter] Failed to tear down reaction stream:", + error + ); } } } @@ -618,7 +667,10 @@ function ensureReactionLiveSubscription(key, pointer) { try { subscription = query.pool.sub(query.relays, query.filters); } catch (error) { - devLogger.warn("[reactionCounter] Failed to subscribe to reaction events:", error); + devLogger.warn( + "[reactionCounter] Failed to subscribe to reaction events:", + error + ); return null; } @@ -660,7 +712,7 @@ function ensureReactionLiveSubscription(key, pointer) { pointerSubscriptions.set(key, { signature, - unsub: unsubscribe, + unsub: unsubscribe }); return unsubscribe; @@ -688,7 +740,8 @@ export const reactionCounter = { })(); const pointerRelay = - typeof publishOptions.pointerRelay === "string" && publishOptions.pointerRelay.trim() + typeof publishOptions.pointerRelay === "string" && + publishOptions.pointerRelay.trim() ? publishOptions.pointerRelay.trim() : pointerRelayFromPointer; @@ -750,7 +803,7 @@ export const reactionCounter = { const result = await nostrClient.publishVideoReaction( enrichedPointer, - publishOptions, + publishOptions ); if (!result || !result.ok) { userLogger.warn( @@ -768,21 +821,24 @@ export const reactionCounter = { ? publishOptions.actorPubkey : undefined, content: publishOptions.content, - created_at: publishOptions.created_at, + created_at: publishOptions.created_at }; if (eventPayload && eventPayload.pubkey) { ingestLocalReaction({ pointer: enrichedPointer, - event: eventPayload, + event: eventPayload }); } } return result; } catch (error) { - userLogger.warn("[reactionCounter] Failed to publish reaction event:", error); + userLogger.warn( + "[reactionCounter] Failed to publish reaction event:", + error + ); throw error; } - }, + } }; reactionCounter.subscribe = subscribeToPointer; @@ -793,5 +849,5 @@ export default reactionCounter; export { subscribeToPointer as subscribeToReactions, unsubscribeFromPointer as unsubscribeFromReactions, - getPointerSnapshot as getReactionSnapshot, + getPointerSnapshot as getReactionSnapshot }; diff --git a/js/ui/reactionController.js b/js/ui/reactionController.js index 6afe8e8f..e1eedbce 100644 --- a/js/ui/reactionController.js +++ b/js/ui/reactionController.js @@ -4,16 +4,16 @@ import { pointerArrayToKey } from "../utils/pointer.js"; export default class ReactionController { constructor({ services = {}, ui = {}, state = {}, callbacks = {} } = {}) { this.services = { - reactionCounter: services.reactionCounter, + reactionCounter: services.reactionCounter }; this.ui = { getVideoModal: ui.getVideoModal || (() => null), - showError: ui.showError || (() => {}), + showError: ui.showError || (() => {}) }; this.state = { getCurrentVideo: state.getCurrentVideo || (() => null), getCurrentVideoPointer: state.getCurrentVideoPointer || (() => null), - getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null), + getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null) }; this.callbacks = { isUserLoggedIn: callbacks.isUserLoggedIn || (() => false), @@ -22,13 +22,13 @@ export default class ReactionController { // Ensure a sign-capable signer before publishing a reaction (kind 7). A // reloaded nsec session can lose its in-memory key; this re-unlocks it with // one passphrase prompt instead of the reaction silently failing (TODO #54). - ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true })), + ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true })) }; this.reactionState = { counts: { "+": 0, "-": 0 }, total: 0, - userReaction: "", + userReaction: "" }; this.unsub = null; this.pointerKey = null; @@ -46,14 +46,14 @@ export default class ReactionController { this.reactionState = { counts: { "+": 0, "-": 0 }, total: 0, - userReaction: "", + userReaction: "" }; const videoModal = this.ui.getVideoModal(); if (videoModal?.updateReactionSummary) { videoModal.updateReactionSummary({ total: 0, counts: { "+": 0, "-": 0 }, - userReaction: "", + userReaction: "" }); } } @@ -65,7 +65,7 @@ export default class ReactionController { } catch (error) { devLogger.warn( "[reaction] Failed to tear down modal subscription:", - error, + error ); } } @@ -88,59 +88,83 @@ export default class ReactionController { } try { - const normalizedUser = this.callbacks.normalizeHexPubkey(this.callbacks.getPubkey()); - const unsubscribe = this.services.reactionCounter.subscribe(pointer, (snapshot) => { - const counts = { ...this.reactionState.counts }; - if (snapshot?.counts && typeof snapshot.counts === "object") { - for (const [key, value] of Object.entries(snapshot.counts)) { - counts[key] = this.normalizeReactionCount(value); - } + const currentVideo = this.state.getCurrentVideo(); + const reactionTarget = (() => { + if (!Array.isArray(pointer) || pointer[0] !== "a") { + return pointer; } - if (!Object.prototype.hasOwnProperty.call(counts, "+")) { - counts["+"] = 0; - } - if (!Object.prototype.hasOwnProperty.call(counts, "-")) { - counts["-"] = 0; - } - - let total = Number.isFinite(snapshot?.total) - ? Math.max(0, Number(snapshot.total)) - : 0; - if (!Number.isFinite(total) || total === 0) { - total = 0; - for (const value of Object.values(counts)) { - total += this.normalizeReactionCount(value); - } - } - - let userReaction = ""; - if (normalizedUser && snapshot?.reactions) { - const record = snapshot.reactions[normalizedUser] || null; - if (record && typeof record.content === "string") { - userReaction = - record.content === "+" - ? "+" - : record.content === "-" - ? "-" - : ""; - } - } - - this.reactionState = { - counts, - total, - userReaction, + const eventId = + typeof currentVideo?.id === "string" && currentVideo.id.trim() + ? currentVideo.id.trim() + : typeof currentVideo?.eventId === "string" && + currentVideo.eventId.trim() + ? currentVideo.eventId.trim() + : ""; + return { + type: "a", + value: pointer[1], + relay: pointer[2], + eventId }; + })(); + const normalizedUser = this.callbacks.normalizeHexPubkey( + this.callbacks.getPubkey() + ); + const unsubscribe = this.services.reactionCounter.subscribe( + reactionTarget, + (snapshot) => { + const counts = { ...this.reactionState.counts }; + if (snapshot?.counts && typeof snapshot.counts === "object") { + for (const [key, value] of Object.entries(snapshot.counts)) { + counts[key] = this.normalizeReactionCount(value); + } + } + if (!Object.prototype.hasOwnProperty.call(counts, "+")) { + counts["+"] = 0; + } + if (!Object.prototype.hasOwnProperty.call(counts, "-")) { + counts["-"] = 0; + } - const currentModal = this.ui.getVideoModal(); - if (currentModal?.updateReactionSummary) { - currentModal.updateReactionSummary({ - total, + let total = Number.isFinite(snapshot?.total) + ? Math.max(0, Number(snapshot.total)) + : 0; + if (!Number.isFinite(total) || total === 0) { + total = 0; + for (const value of Object.values(counts)) { + total += this.normalizeReactionCount(value); + } + } + + let userReaction = ""; + if (normalizedUser && snapshot?.reactions) { + const record = snapshot.reactions[normalizedUser] || null; + if (record && typeof record.content === "string") { + userReaction = + record.content === "+" + ? "+" + : record.content === "-" + ? "-" + : ""; + } + } + + this.reactionState = { counts, - userReaction, - }); + total, + userReaction + }; + + const currentModal = this.ui.getVideoModal(); + if (currentModal?.updateReactionSummary) { + currentModal.updateReactionSummary({ + total, + counts, + userReaction + }); + } } - }); + ); this.pointerKey = pointerKey; this.unsub = () => { @@ -149,7 +173,7 @@ export default class ReactionController { } catch (error) { devLogger.warn( "[reaction] Failed to tear down modal subscription:", - error, + error ); } finally { this.unsub = null; @@ -159,7 +183,7 @@ export default class ReactionController { } catch (error) { devLogger.warn( "[reaction] Failed to subscribe modal reaction counter:", - error, + error ); this.resetState(); } @@ -171,7 +195,7 @@ export default class ReactionController { } const previousCounts = { - ...(this.reactionState?.counts || {}), + ...(this.reactionState?.counts || {}) }; const previousTotalValue = Number.isFinite(this.reactionState?.total) ? Math.max(0, Number(this.reactionState.total)) @@ -206,7 +230,7 @@ export default class ReactionController { const updatedCounts = { ...previousCounts, "+": likeCount, - "-": dislikeCount, + "-": dislikeCount }; let updatedTotal = likeCount + dislikeCount; @@ -217,7 +241,7 @@ export default class ReactionController { this.reactionState = { counts: updatedCounts, total: updatedTotal, - userReaction: nextReaction, + userReaction: nextReaction }; const videoModal = this.ui.getVideoModal(); @@ -225,7 +249,7 @@ export default class ReactionController { videoModal.updateReactionSummary({ total: updatedTotal, counts: updatedCounts, - userReaction: nextReaction, + userReaction: nextReaction }); } @@ -241,7 +265,7 @@ export default class ReactionController { return { counts: previousCounts, total: fallbackPreviousTotal, - userReaction: previousReaction, + userReaction: previousReaction }; } @@ -285,7 +309,7 @@ export default class ReactionController { this.reactionState = { counts, total, - userReaction, + userReaction }; const videoModal = this.ui.getVideoModal(); @@ -293,7 +317,7 @@ export default class ReactionController { videoModal.updateReactionSummary({ total, counts, - userReaction, + userReaction }); } } @@ -307,11 +331,7 @@ export default class ReactionController { const requestedReaction = typeof detail.reaction === "string" ? detail.reaction : ""; const normalizedReaction = - requestedReaction === "+" - ? "+" - : requestedReaction === "-" - ? "-" - : ""; + requestedReaction === "+" ? "+" : requestedReaction === "-" ? "-" : ""; if (!normalizedReaction) { return; @@ -319,14 +339,16 @@ export default class ReactionController { const previousReaction = this.reactionState?.userReaction || ""; const pointer = this.state.getCurrentVideoPointer(); - const pointerKey = this.state.getCurrentVideoPointerKey() || (pointer ? pointerArrayToKey(pointer) : null); + const pointerKey = + this.state.getCurrentVideoPointerKey() || + (pointer ? pointerArrayToKey(pointer) : null); if (!pointer || !pointerKey) { if (videoModal) { videoModal.setUserReaction(previousReaction); } devLogger.info( - "[reaction] Ignoring reaction request until modal pointer is available.", + "[reaction] Ignoring reaction request until modal pointer is available." ); return; } @@ -350,7 +372,7 @@ export default class ReactionController { need: "sign", pubkey: this.callbacks.getPubkey(), promptMessage: - "Re-enter your PIN / passphrase to unlock your key and react to videos.", + "Re-enter your PIN / passphrase to unlock your key and react to videos." }); } catch (error) { devLogger.warn("[reaction] Signer gate failed:", error); @@ -359,7 +381,10 @@ export default class ReactionController { if (ensured && ensured.ok === false) { videoModal.setUserReaction(previousReaction); // cancelled = user backed out; bad-passphrase already showed its own toast. - if (ensured.reason !== "cancelled" && ensured.reason !== "bad-passphrase") { + if ( + ensured.reason !== "cancelled" && + ensured.reason !== "bad-passphrase" + ) { this.ui.showError("Connect a signer to react to videos."); } return; @@ -367,11 +392,12 @@ export default class ReactionController { let rollbackSnapshot = null; try { - rollbackSnapshot = this.applyOptimisticUpdate( - normalizedReaction - ); + rollbackSnapshot = this.applyOptimisticUpdate(normalizedReaction); } catch (error) { - devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error); + devLogger.warn( + "[reaction] Failed to apply optimistic reaction state:", + error + ); } try { @@ -380,7 +406,7 @@ export default class ReactionController { content: normalizedReaction, video: currentVideo, currentVideoPubkey: currentVideo?.pubkey, - pointerKey, + pointerKey }); if (!result?.ok) { diff --git a/tests/reaction-counter.test.mjs b/tests/reaction-counter.test.mjs new file mode 100644 index 00000000..2a9ce399 --- /dev/null +++ b/tests/reaction-counter.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildReactionFilters } from "../js/reactionCounter.js"; + +test("addressable video reactions query both address and immutable event references", () => { + const filters = buildReactionFilters( + { + type: "a", + value: "34235:creator:episode-42", + relay: "wss://relay.example", + eventId: "immutable-video-event-id" + }, + { limit: 400, since: 123 } + ); + + assert.deepEqual(filters, [ + { + kinds: [7], + "#a": ["34235:creator:episode-42"], + limit: 400, + since: 123 + }, + { + kinds: [7], + "#e": ["immutable-video-event-id"], + limit: 400, + since: 123 + } + ]); +}); + +test("event-addressed video reactions keep a single immutable event query", () => { + assert.deepEqual( + buildReactionFilters({ type: "e", value: "video-event-id" }), + [{ kinds: [7], "#e": ["video-event-id"] }] + ); +}); diff --git a/tests/ui/reactionController.test.mjs b/tests/ui/reactionController.test.mjs index 40128610..67b6ba33 100644 --- a/tests/ui/reactionController.test.mjs +++ b/tests/ui/reactionController.test.mjs @@ -15,36 +15,36 @@ describe("ReactionController", () => { subscribe: (pointer, cb) => { return () => {}; }, - publish: async () => ({ ok: true }), + publish: async () => ({ ok: true }) }; mockVideoModal = { updateReactionSummary: () => {}, - setUserReaction: () => {}, + setUserReaction: () => {} }; mockUi = { getVideoModal: () => mockVideoModal, - showError: () => {}, + showError: () => {} }; mockState = { getCurrentVideo: () => ({ id: "v1", pubkey: "p1" }), getCurrentVideoPointer: () => ["e", "v1"], - getCurrentVideoPointerKey: () => "e:v1", + getCurrentVideoPointerKey: () => "e:v1" }; mockCallbacks = { isUserLoggedIn: () => true, normalizeHexPubkey: (val) => val, - getPubkey: () => "user1", + getPubkey: () => "user1" }; controller = new ReactionController({ services: { reactionCounter: mockReactionCounter }, ui: mockUi, state: mockState, - callbacks: mockCallbacks, + callbacks: mockCallbacks }); }); @@ -54,7 +54,26 @@ describe("ReactionController", () => { controller.subscribe(["e", "v1"], "e:v1"); assert.strictEqual(subscribeMock.mock.callCount(), 1); - assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], ["e", "v1"]); + assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], [ + "e", + "v1" + ]); + }); + + it("includes the immutable event ID when subscribing to addressable videos", (t) => { + const subscribeMock = t.mock.method(mockReactionCounter, "subscribe"); + + controller.subscribe( + ["a", "34235:p1:episode-42", "wss://relay.example"], + "a:34235:p1:episode-42" + ); + + assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], { + type: "a", + value: "34235:p1:episode-42", + relay: "wss://relay.example", + eventId: "v1" + }); }); it("should handle reaction update from subscription", (t) => { @@ -63,35 +82,41 @@ describe("ReactionController", () => { subCallback = cb; return () => {}; }; - const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary"); + const updateSummaryMock = t.mock.method( + mockVideoModal, + "updateReactionSummary" + ); controller.subscribe(["e", "v1"], "e:v1"); // subscribe calls unsubscribe which calls resetState which calls updateReactionSummary (1st call) subCallback({ - total: 10, - counts: { "+": 10 }, - reactions: { "user1": { content: "+" } } + total: 10, + counts: { "+": 10 }, + reactions: { user1: { content: "+" } } }); // subCallback calls updateReactionSummary (2nd call) assert.strictEqual(updateSummaryMock.mock.callCount(), 2); assert.deepStrictEqual(updateSummaryMock.mock.calls[1].arguments[0], { - total: 10, - counts: { "+": 10, "-": 0 }, - userReaction: "+" + total: 10, + counts: { "+": 10, "-": 0 }, + userReaction: "+" }); }); it("should apply optimistic update on handleReaction", async (t) => { - const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary"); + const updateSummaryMock = t.mock.method( + mockVideoModal, + "updateReactionSummary" + ); const publishMock = t.mock.method(mockReactionCounter, "publish"); // Pre-seed state controller.reactionState = { - total: 5, - counts: { "+": 5, "-": 0 }, - userReaction: "" + total: 5, + counts: { "+": 5, "-": 0 }, + userReaction: "" }; await controller.handleReaction({ reaction: "+" }); @@ -110,16 +135,19 @@ describe("ReactionController", () => { it("should rollback optimistic update on publish failure", async (t) => { mockReactionCounter.publish = async () => ({ ok: false }); - const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary"); + const updateSummaryMock = t.mock.method( + mockVideoModal, + "updateReactionSummary" + ); // We need to update controller.ui.showError because constructor copied the reference const showErrorMock = t.mock.method(mockUi, "showError"); controller.ui.showError = showErrorMock; controller.reactionState = { - total: 5, - counts: { "+": 5, "-": 0 }, - userReaction: "" + total: 5, + counts: { "+": 5, "-": 0 }, + userReaction: "" }; await controller.handleReaction({ reaction: "+" });