Files
bitvid/js/ui/reactionController.js
T
thePR0M3TH3ANandClaude Opus 4.8 1622e85d10 [auth] Route reactions, blocks, subscriptions & DM send through the signer gate (#54/#57)
Fast-follows to the ensureEncryptionCapableSigner gate: every remaining
signing/encryption flow now re-unlocks a reloaded nsec signer with one passphrase
prompt instead of dead-ending. Each call site short-circuits ONLY on the
user-driven outcomes (cancel / bad-passphrase); every other case falls through to
the flow's own existing error handling, so non-nsec signers (e.g. an unresponsive
NIP-07 extension) behave exactly as before.

- Reactions (#54): ReactionController.handleReaction gates before the optimistic
  update with need:"sign" (kind-7 reactions don't need encryption) — fixes the
  "like/dislike does nothing" that was a rolled-back publish on a lost signer.
- Blocks: handleProfileBlocklistMutation (need:"encrypt", NIP-04 mute/block lists).
- Subscriptions: the channel Subscribe/Unsubscribe toggle (need:"encrypt", kind 30000).
- DM send: both composers — handleSendProfileMessage and handleDmAppShellSendMessage
  (need:"encrypt", NIP-17/NIP-04).

Reactions get the gate via a new ensureSigner callback on ReactionController; the
modal flows use the services.ensureEncryptionCapableSigner already exposed.

Tests: tests/reaction-signer-gate.test.mjs (3 — cancelled/bad-passphrase block
publish, ok proceeds, asserts need:"sign"). Related suites (decision, DM, profile
modal, switch delegators) green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:23:04 -04:00

401 lines
11 KiB
JavaScript

import { devLogger } from "../utils/logger.js";
import { pointerArrayToKey } from "../utils/pointer.js";
export default class ReactionController {
constructor({ services = {}, ui = {}, state = {}, callbacks = {} } = {}) {
this.services = {
reactionCounter: services.reactionCounter,
};
this.ui = {
getVideoModal: ui.getVideoModal || (() => null),
showError: ui.showError || (() => {}),
};
this.state = {
getCurrentVideo: state.getCurrentVideo || (() => null),
getCurrentVideoPointer: state.getCurrentVideoPointer || (() => null),
getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null),
};
this.callbacks = {
isUserLoggedIn: callbacks.isUserLoggedIn || (() => false),
normalizeHexPubkey: callbacks.normalizeHexPubkey || ((val) => val),
getPubkey: callbacks.getPubkey || (() => null),
// 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 })),
};
this.reactionState = {
counts: { "+": 0, "-": 0 },
total: 0,
userReaction: "",
};
this.unsub = null;
this.pointerKey = null;
}
normalizeReactionCount(value) {
if (Number.isFinite(value)) {
return Math.max(0, Math.round(Number(value)));
}
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : 0;
}
resetState() {
this.reactionState = {
counts: { "+": 0, "-": 0 },
total: 0,
userReaction: "",
};
const videoModal = this.ui.getVideoModal();
if (videoModal?.updateReactionSummary) {
videoModal.updateReactionSummary({
total: 0,
counts: { "+": 0, "-": 0 },
userReaction: "",
});
}
}
unsubscribe() {
if (typeof this.unsub === "function") {
try {
this.unsub();
} catch (error) {
devLogger.warn(
"[reaction] Failed to tear down modal subscription:",
error,
);
}
}
this.unsub = null;
this.pointerKey = null;
this.resetState();
}
subscribe(pointer, pointerKey) {
const videoModal = this.ui.getVideoModal();
if (!videoModal?.updateReactionSummary) {
this.unsubscribe();
return;
}
this.unsubscribe();
if (!pointer || !pointerKey) {
return;
}
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);
}
}
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 currentModal = this.ui.getVideoModal();
if (currentModal?.updateReactionSummary) {
currentModal.updateReactionSummary({
total,
counts,
userReaction,
});
}
});
this.pointerKey = pointerKey;
this.unsub = () => {
try {
unsubscribe?.();
} catch (error) {
devLogger.warn(
"[reaction] Failed to tear down modal subscription:",
error,
);
} finally {
this.unsub = null;
this.pointerKey = null;
}
};
} catch (error) {
devLogger.warn(
"[reaction] Failed to subscribe modal reaction counter:",
error,
);
this.resetState();
}
}
applyOptimisticUpdate(nextReaction) {
if (nextReaction !== "+" && nextReaction !== "-") {
return null;
}
const previousCounts = {
...(this.reactionState?.counts || {}),
};
const previousTotalValue = Number.isFinite(this.reactionState?.total)
? Math.max(0, Number(this.reactionState.total))
: null;
const previousReaction = this.reactionState?.userReaction || "";
const likeBefore = this.normalizeReactionCount(previousCounts["+"]);
const dislikeBefore = this.normalizeReactionCount(previousCounts["-"]);
const otherCounts = {};
for (const [key, value] of Object.entries(previousCounts)) {
if (key === "+" || key === "-") {
continue;
}
otherCounts[key] = this.normalizeReactionCount(value);
}
let likeCount = likeBefore;
let dislikeCount = dislikeBefore;
if (previousReaction === "+") {
likeCount = Math.max(0, likeCount - 1);
} else if (previousReaction === "-") {
dislikeCount = Math.max(0, dislikeCount - 1);
}
if (nextReaction === "+") {
likeCount += 1;
} else if (nextReaction === "-") {
dislikeCount += 1;
}
const updatedCounts = {
...previousCounts,
"+": likeCount,
"-": dislikeCount,
};
let updatedTotal = likeCount + dislikeCount;
for (const value of Object.values(otherCounts)) {
updatedTotal += this.normalizeReactionCount(value);
}
this.reactionState = {
counts: updatedCounts,
total: updatedTotal,
userReaction: nextReaction,
};
const videoModal = this.ui.getVideoModal();
if (videoModal?.updateReactionSummary) {
videoModal.updateReactionSummary({
total: updatedTotal,
counts: updatedCounts,
userReaction: nextReaction,
});
}
const fallbackPreviousTotal = Number.isFinite(previousTotalValue)
? previousTotalValue
: likeBefore +
dislikeBefore +
Object.values(otherCounts).reduce(
(sum, value) => sum + this.normalizeReactionCount(value),
0
);
return {
counts: previousCounts,
total: fallbackPreviousTotal,
userReaction: previousReaction,
};
}
restoreSnapshot(snapshot) {
if (!snapshot) {
return;
}
const countsInput =
snapshot.counts && typeof snapshot.counts === "object"
? snapshot.counts
: {};
const counts = { ...countsInput };
for (const [key, value] of Object.entries(counts)) {
counts[key] = this.normalizeReactionCount(value);
}
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);
}
}
const userReaction =
snapshot.userReaction === "+"
? "+"
: snapshot.userReaction === "-"
? "-"
: "";
this.reactionState = {
counts,
total,
userReaction,
};
const videoModal = this.ui.getVideoModal();
if (videoModal?.updateReactionSummary) {
videoModal.updateReactionSummary({
total,
counts,
userReaction,
});
}
}
async handleReaction(detail = {}) {
const videoModal = this.ui.getVideoModal();
if (!videoModal) {
return;
}
const requestedReaction =
typeof detail.reaction === "string" ? detail.reaction : "";
const normalizedReaction =
requestedReaction === "+"
? "+"
: requestedReaction === "-"
? "-"
: "";
if (!normalizedReaction) {
return;
}
const previousReaction = this.reactionState?.userReaction || "";
const pointer = this.state.getCurrentVideoPointer();
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.",
);
return;
}
if (normalizedReaction === previousReaction) {
return;
}
if (!this.callbacks.isUserLoggedIn()) {
this.ui.showError("Please login to react to videos.");
videoModal.setUserReaction(previousReaction);
return;
}
// A reloaded nsec session keeps the pubkey/UI but drops the in-memory signer,
// so publishing the kind-7 reaction would fail with a generic "Failed to send
// reaction" and the button would appear dead. Re-unlock the signer first (one
// passphrase prompt). Gate BEFORE the optimistic update so nothing flashes.
let ensured = { ok: true };
try {
ensured = await this.callbacks.ensureSigner({
need: "sign",
pubkey: this.callbacks.getPubkey(),
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and react to videos.",
});
} catch (error) {
devLogger.warn("[reaction] Signer gate failed:", error);
ensured = { ok: false, reason: "ensure-failed" };
}
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") {
this.ui.showError("Connect a signer to react to videos.");
}
return;
}
let rollbackSnapshot = null;
try {
rollbackSnapshot = this.applyOptimisticUpdate(
normalizedReaction
);
} catch (error) {
devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error);
}
try {
const currentVideo = this.state.getCurrentVideo();
const result = await this.services.reactionCounter.publish(pointer, {
content: normalizedReaction,
video: currentVideo,
currentVideoPubkey: currentVideo?.pubkey,
pointerKey,
});
if (!result?.ok) {
if (rollbackSnapshot) {
this.restoreSnapshot(rollbackSnapshot);
}
this.ui.showError("Failed to send reaction. Please try again.");
}
} catch (error) {
devLogger.warn("[reaction] Failed to publish reaction:", error);
if (rollbackSnapshot) {
this.restoreSnapshot(rollbackSnapshot);
}
this.ui.showError("Failed to send reaction. Please try again.");
}
}
}