Files
bitvid/js/ui/reactionController.js
T

427 lines
12 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 currentVideo = this.state.getCurrentVideo();
const reactionTarget = (() => {
if (!Array.isArray(pointer) || pointer[0] !== "a") {
return pointer;
}
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;
}
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.");
}
}
}