fix(reactions): load legacy event-tagged video reactions

This commit is contained in:
thePR0M3TH3AN committed 2026-07-26 14:45:38 -04:00
1 parent c200c84fc5
commit 4dd150b010
4 files changed
+287 -139

No files matched your search

+94 -38
View File
@@ -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
};
+105 -79
View File
@@ -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) {
+38
View File
@@ -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"] }]
);
});
+50 -22
View File
@@ -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: "+" });