Merge pull request #1623 from PR0M3TH3AN/ai/refactor-reactions-20241026-2487608613137550346

Refactor reaction logic to ReactionController
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-24 14:28:15 -05:00
commit 84a6a450dc
3 files changed
+564 -326

No files matched your search

+39 -326
View File
@@ -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() : "";
+371
View File
@@ -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.");
}
}
}
+154
View File
@@ -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);
});
});