mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 08:52:25 +00:00
Merge pull request #1623 from PR0M3TH3AN/ai/refactor-reactions-20241026-2487608613137550346
Refactor reaction logic to ReactionController
This commit is contained in:
3 files changed
+564
-326
No files matched your search
@@ -112,6 +112,7 @@ import {
|
||||
} from "./ui/components/staticModalAccessibility.js";
|
||||
import LoginModalController from "./ui/loginModalController.js";
|
||||
import TagPreferenceMenuController from "./ui/tagPreferenceMenuController.js";
|
||||
import ReactionController from "./ui/reactionController.js";
|
||||
import { pointerArrayToKey } from "./utils/pointer.js";
|
||||
import resolveVideoPointer, {
|
||||
buildVideoAddressPointer,
|
||||
@@ -254,11 +255,39 @@ class Application {
|
||||
onRemovePoster: (reason) => this.forceRemoveModalPoster(reason),
|
||||
});
|
||||
|
||||
this.reactionController = new ReactionController({
|
||||
services: { reactionCounter },
|
||||
ui: {
|
||||
getVideoModal: () => this.videoModal,
|
||||
showError: (msg) => this.showError(msg),
|
||||
},
|
||||
state: {
|
||||
getCurrentVideo: () => this.currentVideo,
|
||||
getCurrentVideoPointer: () => this.currentVideoPointer,
|
||||
getCurrentVideoPointerKey: () => this.currentVideoPointerKey,
|
||||
},
|
||||
callbacks: {
|
||||
isUserLoggedIn: () => this.isUserLoggedIn(),
|
||||
normalizeHexPubkey: (val) => this.normalizeHexPubkey(val),
|
||||
getPubkey: () => this.pubkey,
|
||||
},
|
||||
});
|
||||
|
||||
this.unsubscribeFromPubkeyState = subscribeToAppStateKey(
|
||||
"pubkey",
|
||||
(next, previous) => {
|
||||
if (next !== previous) {
|
||||
this.renderSavedProfiles();
|
||||
if (
|
||||
this.reactionController &&
|
||||
this.currentVideoPointer &&
|
||||
this.currentVideoPointerKey
|
||||
) {
|
||||
this.reactionController.subscribe(
|
||||
this.currentVideoPointer,
|
||||
this.currentVideoPointerKey
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -361,13 +390,6 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
hideModal() {
|
||||
if (this.videoModal) {
|
||||
this.videoModal.close();
|
||||
}
|
||||
// Also clear the active modal state if tracked elsewhere, though videoModal.close() usually handles it.
|
||||
}
|
||||
|
||||
loadSavedProfilesFromStorage() {
|
||||
const result = this.authService.loadSavedProfilesFromStorage();
|
||||
this.renderSavedProfiles();
|
||||
@@ -1344,122 +1366,6 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
resetModalReactionState() {
|
||||
this.modalReactionState = {
|
||||
counts: { "+": 0, "-": 0 },
|
||||
total: 0,
|
||||
userReaction: "",
|
||||
};
|
||||
if (this.videoModal?.updateReactionSummary) {
|
||||
this.videoModal.updateReactionSummary({
|
||||
total: 0,
|
||||
counts: { "+": 0, "-": 0 },
|
||||
userReaction: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
teardownModalReactionSubscription() {
|
||||
if (typeof this.modalReactionUnsub === "function") {
|
||||
try {
|
||||
this.modalReactionUnsub();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[reaction] Failed to tear down modal subscription:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.modalReactionUnsub = null;
|
||||
this.modalReactionPointerKey = null;
|
||||
this.resetModalReactionState();
|
||||
}
|
||||
|
||||
subscribeModalReactions(pointer, pointerKey) {
|
||||
if (!this.videoModal?.updateReactionSummary) {
|
||||
this.teardownModalReactionSubscription();
|
||||
return;
|
||||
}
|
||||
|
||||
this.teardownModalReactionSubscription();
|
||||
|
||||
if (!pointer || !pointerKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
|
||||
const unsubscribe = reactionCounter.subscribe(pointer, (snapshot) => {
|
||||
const counts = { ...this.modalReactionState.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.modalReactionState = {
|
||||
counts,
|
||||
total,
|
||||
userReaction,
|
||||
};
|
||||
this.videoModal.updateReactionSummary({
|
||||
total,
|
||||
counts,
|
||||
userReaction,
|
||||
});
|
||||
});
|
||||
|
||||
this.modalReactionPointerKey = pointerKey;
|
||||
this.modalReactionUnsub = () => {
|
||||
try {
|
||||
unsubscribe?.();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[reaction] Failed to tear down modal subscription:",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
this.modalReactionUnsub = null;
|
||||
this.modalReactionPointerKey = null;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[reaction] Failed to subscribe modal reaction counter:",
|
||||
error,
|
||||
);
|
||||
this.resetModalReactionState();
|
||||
}
|
||||
}
|
||||
|
||||
initializeCommentController() {
|
||||
if (!this.commentThreadService || !this.videoModal) {
|
||||
@@ -1591,206 +1497,9 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
applyModalReactionOptimisticUpdate(nextReaction) {
|
||||
if (nextReaction !== "+" && nextReaction !== "-") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousCounts = {
|
||||
...(this.modalReactionState?.counts || {}),
|
||||
};
|
||||
const previousTotalValue = Number.isFinite(this.modalReactionState?.total)
|
||||
? Math.max(0, Number(this.modalReactionState.total))
|
||||
: null;
|
||||
const previousReaction = this.modalReactionState?.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.modalReactionState = {
|
||||
counts: updatedCounts,
|
||||
total: updatedTotal,
|
||||
userReaction: nextReaction,
|
||||
};
|
||||
|
||||
if (this.videoModal?.updateReactionSummary) {
|
||||
this.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,
|
||||
};
|
||||
}
|
||||
|
||||
restoreModalReactionSnapshot(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.modalReactionState = {
|
||||
counts,
|
||||
total,
|
||||
userReaction,
|
||||
};
|
||||
|
||||
if (this.videoModal?.updateReactionSummary) {
|
||||
this.videoModal.updateReactionSummary({
|
||||
total,
|
||||
counts,
|
||||
userReaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleVideoReaction(detail = {}) {
|
||||
if (!this.videoModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedReaction =
|
||||
typeof detail.reaction === "string" ? detail.reaction : "";
|
||||
const normalizedReaction =
|
||||
requestedReaction === "+"
|
||||
? "+"
|
||||
: requestedReaction === "-"
|
||||
? "-"
|
||||
: "";
|
||||
|
||||
if (!normalizedReaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousReaction = this.modalReactionState?.userReaction || "";
|
||||
const pointer = this.currentVideoPointer;
|
||||
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
|
||||
if (!pointer || !pointerKey) {
|
||||
if (this.videoModal) {
|
||||
this.videoModal.setUserReaction(previousReaction);
|
||||
}
|
||||
devLogger.info(
|
||||
"[reaction] Ignoring reaction request until modal pointer is available.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (normalizedReaction === previousReaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isUserLoggedIn()) {
|
||||
this.showError("Please login to react to videos.");
|
||||
this.videoModal.setUserReaction(previousReaction);
|
||||
return;
|
||||
}
|
||||
|
||||
let rollbackSnapshot = null;
|
||||
try {
|
||||
rollbackSnapshot = this.applyModalReactionOptimisticUpdate(
|
||||
normalizedReaction
|
||||
);
|
||||
} catch (error) {
|
||||
devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await reactionCounter.publish(pointer, {
|
||||
content: normalizedReaction,
|
||||
video: this.currentVideo,
|
||||
currentVideoPubkey: this.currentVideo?.pubkey,
|
||||
pointerKey,
|
||||
});
|
||||
|
||||
if (!result?.ok) {
|
||||
if (rollbackSnapshot) {
|
||||
this.restoreModalReactionSnapshot(rollbackSnapshot);
|
||||
}
|
||||
this.showError("Failed to send reaction. Please try again.");
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[reaction] Failed to publish reaction:", error);
|
||||
if (rollbackSnapshot) {
|
||||
this.restoreModalReactionSnapshot(rollbackSnapshot);
|
||||
}
|
||||
this.showError("Failed to send reaction. Please try again.");
|
||||
if (this.reactionController) {
|
||||
return this.reactionController.handleReaction(detail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4773,7 +4482,9 @@ class Application {
|
||||
this.playbackService.cleanupWatchdog();
|
||||
}
|
||||
this.teardownModalViewCountSubscription();
|
||||
this.teardownModalReactionSubscription();
|
||||
if (this.reactionController) {
|
||||
this.reactionController.unsubscribe();
|
||||
}
|
||||
|
||||
if (!preserveObservers && this.mediaLoader) {
|
||||
this.mediaLoader.disconnect();
|
||||
@@ -5444,7 +5155,9 @@ class Application {
|
||||
this.cancelPendingViewLogging();
|
||||
this.clearActiveIntervals();
|
||||
this.teardownModalViewCountSubscription();
|
||||
this.teardownModalReactionSubscription();
|
||||
if (this.reactionController) {
|
||||
this.reactionController.unsubscribe();
|
||||
}
|
||||
this.pendingModeratedPlayback = null;
|
||||
if (
|
||||
this.videoModal &&
|
||||
@@ -8981,7 +8694,7 @@ class Application {
|
||||
this.currentVideoPointer,
|
||||
this.currentVideoPointerKey
|
||||
);
|
||||
this.subscribeModalReactions(
|
||||
this.reactionController.subscribe(
|
||||
this.currentVideoPointer,
|
||||
this.currentVideoPointerKey
|
||||
);
|
||||
@@ -9320,7 +9033,7 @@ class Application {
|
||||
this.currentVideoPointer = null;
|
||||
this.currentVideoPointerKey = null;
|
||||
this.subscribeModalViewCount(null, null);
|
||||
this.subscribeModalReactions(null, null);
|
||||
this.reactionController.subscribe(null, null);
|
||||
this.pendingModeratedPlayback = null;
|
||||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
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),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import ReactionController from "../../js/ui/reactionController.js";
|
||||
|
||||
describe("ReactionController", () => {
|
||||
let controller;
|
||||
let mockReactionCounter;
|
||||
let mockVideoModal;
|
||||
let mockState;
|
||||
let mockCallbacks;
|
||||
let mockUi;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReactionCounter = {
|
||||
subscribe: (pointer, cb) => {
|
||||
return () => {};
|
||||
},
|
||||
publish: async () => ({ ok: true }),
|
||||
};
|
||||
|
||||
mockVideoModal = {
|
||||
updateReactionSummary: () => {},
|
||||
setUserReaction: () => {},
|
||||
};
|
||||
|
||||
mockUi = {
|
||||
getVideoModal: () => mockVideoModal,
|
||||
showError: () => {},
|
||||
};
|
||||
|
||||
mockState = {
|
||||
getCurrentVideo: () => ({ id: "v1", pubkey: "p1" }),
|
||||
getCurrentVideoPointer: () => ["e", "v1"],
|
||||
getCurrentVideoPointerKey: () => "e:v1",
|
||||
};
|
||||
|
||||
mockCallbacks = {
|
||||
isUserLoggedIn: () => true,
|
||||
normalizeHexPubkey: (val) => val,
|
||||
getPubkey: () => "user1",
|
||||
};
|
||||
|
||||
controller = new ReactionController({
|
||||
services: { reactionCounter: mockReactionCounter },
|
||||
ui: mockUi,
|
||||
state: mockState,
|
||||
callbacks: mockCallbacks,
|
||||
});
|
||||
});
|
||||
|
||||
it("should subscribe to reactions", (t) => {
|
||||
const subscribeMock = t.mock.method(mockReactionCounter, "subscribe");
|
||||
|
||||
controller.subscribe(["e", "v1"], "e:v1");
|
||||
|
||||
assert.strictEqual(subscribeMock.mock.callCount(), 1);
|
||||
assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], ["e", "v1"]);
|
||||
});
|
||||
|
||||
it("should handle reaction update from subscription", (t) => {
|
||||
let subCallback;
|
||||
mockReactionCounter.subscribe = (pointer, cb) => {
|
||||
subCallback = cb;
|
||||
return () => {};
|
||||
};
|
||||
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: "+" } }
|
||||
});
|
||||
// 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: "+"
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply optimistic update on handleReaction", async (t) => {
|
||||
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: ""
|
||||
};
|
||||
|
||||
await controller.handleReaction({ reaction: "+" });
|
||||
|
||||
// Verify optimistic update call
|
||||
// Should increment + count and total
|
||||
assert.strictEqual(updateSummaryMock.mock.callCount(), 1);
|
||||
const updateArgs = updateSummaryMock.mock.calls[0].arguments[0];
|
||||
assert.strictEqual(updateArgs.total, 6);
|
||||
assert.strictEqual(updateArgs.counts["+"], 6);
|
||||
assert.strictEqual(updateArgs.userReaction, "+");
|
||||
|
||||
// Verify publish call
|
||||
assert.strictEqual(publishMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("should rollback optimistic update on publish failure", async (t) => {
|
||||
mockReactionCounter.publish = async () => ({ ok: false });
|
||||
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: ""
|
||||
};
|
||||
|
||||
await controller.handleReaction({ reaction: "+" });
|
||||
|
||||
// 1st call: optimistic update
|
||||
// 2nd call: restore snapshot
|
||||
assert.strictEqual(updateSummaryMock.mock.callCount(), 2);
|
||||
|
||||
// Restore check
|
||||
const restoreArgs = updateSummaryMock.mock.calls[1].arguments[0];
|
||||
assert.strictEqual(restoreArgs.total, 5);
|
||||
assert.strictEqual(restoreArgs.counts["+"], 5);
|
||||
assert.strictEqual(restoreArgs.userReaction, "");
|
||||
|
||||
assert.strictEqual(showErrorMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("should not react if user not logged in", async (t) => {
|
||||
// Update callback on controller instance
|
||||
controller.callbacks.isUserLoggedIn = () => false;
|
||||
|
||||
const showErrorMock = t.mock.method(mockUi, "showError");
|
||||
controller.ui.showError = showErrorMock;
|
||||
|
||||
const publishMock = t.mock.method(mockReactionCounter, "publish");
|
||||
|
||||
await controller.handleReaction({ reaction: "+" });
|
||||
|
||||
assert.strictEqual(showErrorMock.mock.callCount(), 1);
|
||||
assert.strictEqual(publishMock.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user