mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
427 lines
12 KiB
JavaScript
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.");
|
|
}
|
|
}
|
|
}
|