mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix(reactions): load legacy event-tagged video reactions
This commit is contained in:
1 parent
c200c84fc5
commit
4dd150b010
4 files changed
+287
-139
No files matched your search
+94
-38
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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"] }]
|
||||
);
|
||||
});
|
||||
@@ -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: "+" });
|
||||
|
||||
Reference in new issue
Block a user