From 4e108f3e43465e2de28c87c000b0fd5a770645ed Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:06:41 -0400 Subject: [PATCH] Align video modal reactions row --- components/video-modal.html | 415 ++++++++++++++++++++------------- css/tailwind.source.css | 82 ++++++- js/app.js | 345 +++++++++++++++++++++++++++ js/reactionCounter.js | 77 ++++++ js/ui/components/VideoModal.js | 253 ++++++++++++++++++++ 5 files changed, 1010 insertions(+), 162 deletions(-) diff --git a/components/video-modal.html b/components/video-modal.html index f12f0471..6375a180 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -85,183 +85,104 @@ -
- -
- + Like + 0 + + +
- -
+
+
+ + +
+ +
+ +
diff --git a/css/tailwind.source.css b/css/tailwind.source.css index e9df3d1c..95b66fd0 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -1725,7 +1725,87 @@ } .video-modal__actions-row { - @apply flex flex-wrap items-center gap-3; + @apply flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between; + } + + .video-modal__reactions { + @apply flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4; + } + + .video-modal__reaction-buttons { + @apply inline-flex flex-wrap items-center gap-2; + } + + .video-modal__reaction-button { + --video-modal-reaction-active-bg: color-mix( + in srgb, + var(--color-accent) 22%, + var(--surface-overlay-panel) + ); + --video-modal-reaction-active-border: color-mix( + in srgb, + var(--color-accent) 38%, + var(--border-overlay) + ); + } + + .video-modal__reaction-button[aria-pressed="true"] { + color: var(--color-accent-strong); + background-color: var(--video-modal-reaction-active-bg); + border-color: var(--video-modal-reaction-active-border); + } + + .video-modal__reaction-button[aria-pressed="true"] + .video-modal__reaction-count { + color: var(--color-accent-strong); + } + + .video-modal__reaction-count { + @apply text-sm font-semibold text-muted; + } + + .video-modal__reaction-meter { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; + flex: 1 1 var(--menu-min-width); + max-inline-size: 100%; + } + + .video-modal__reaction-meter-label { + @apply text-sm font-medium text-muted; + min-inline-size: 6rem; + } + + .video-modal__reaction-meter-track { + position: relative; + flex: 1 1 auto; + inline-size: 100%; + block-size: var(--space-xs); + border-radius: var(--radius-full); + background-color: var(--color-border-muted); + overflow: hidden; + } + + .video-modal__reaction-meter-fill { + position: absolute; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 0%; + max-inline-size: 100%; + background-image: linear-gradient( + 90deg, + var(--color-accent) 0%, + var(--color-accent-strong) 100% + ); + transition: inline-size var(--motion-duration-base) + var(--motion-ease-out); + } + + .video-modal__actions-group { + @apply flex flex-wrap items-center gap-3 justify-end; } .video-modal__action-button { diff --git a/js/app.js b/js/app.js index 73b0ca55..5d5f9f9f 100644 --- a/js/app.js +++ b/js/app.js @@ -73,6 +73,7 @@ import { truncateMiddle, formatShortNpub, } from "./utils/formatters.js"; +import reactionCounter from "./reactionCounter.js"; import { escapeHTML as escapeHtml, removeTrackingScripts, @@ -722,6 +723,14 @@ class Application { "video:share", this.boundVideoModalShareHandler ); + this.boundVideoModalReactionHandler = (event) => { + const detail = event?.detail || {}; + this.handleVideoReaction(detail); + }; + this.videoModal.addEventListener( + "video:reaction", + this.boundVideoModalReactionHandler + ); this.boundVideoModalContextActionHandler = (event) => { const detail = event?.detail || {}; const action = typeof detail.action === "string" ? detail.action : ""; @@ -878,6 +887,13 @@ class Application { this.videoSubscription = this.nostrService.getVideoSubscription() || null; this.videoList = null; this.modalViewCountUnsub = null; + this.modalReactionUnsub = null; + this.modalReactionPointerKey = null; + this.modalReactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: "", + }; // Videos stored as a Map (key=event.id) this.videosMap = this.nostrService.getVideosMap(); @@ -1747,6 +1763,123 @@ 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(); + } + } + subscribeModalViewCount(pointer, pointerKey) { const viewEl = this.videoModal?.getViewCountElement() || null; if (!viewEl) { @@ -1812,6 +1945,203 @@ 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 pointer = this.currentVideoPointer; + if (!pointer) { + this.showError("Reactions are unavailable for this video."); + return; + } + + const requestedReaction = + typeof detail.reaction === "string" ? detail.reaction : ""; + const normalizedReaction = + requestedReaction === "+" + ? "+" + : requestedReaction === "-" + ? "-" + : ""; + + if (!normalizedReaction) { + return; + } + + const previousReaction = this.modalReactionState?.userReaction || ""; + 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, + }); + + 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."); + } + } + syncProfileModalState({ includeSavedProfiles = true, includeActivePubkey = true, @@ -3872,6 +4202,7 @@ class Application { this.playbackService.cleanupWatchdog(); } this.teardownModalViewCountSubscription(); + this.teardownModalReactionSubscription(); if (!preserveObservers && this.mediaLoader) { this.mediaLoader.disconnect(); @@ -4438,6 +4769,7 @@ class Application { this.cancelPendingViewLogging(); this.clearActiveIntervals(); this.teardownModalViewCountSubscription(); + this.teardownModalReactionSubscription(); // 2) Close the modal UI right away so the user gets instant feedback const modalVideoElement = @@ -7461,6 +7793,10 @@ class Application { this.currentVideoPointer, this.currentVideoPointerKey ); + this.subscribeModalReactions( + this.currentVideoPointer, + this.currentVideoPointerKey + ); this.syncModalMoreMenuData(); @@ -7767,6 +8103,7 @@ class Application { this.currentVideoPointer = null; this.currentVideoPointerKey = null; this.subscribeModalViewCount(null, null); + this.subscribeModalReactions(null, null); const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; @@ -7881,6 +8218,14 @@ class Application { return 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; + } + normalizeHexPubkey(pubkey) { if (typeof pubkey !== "string") { return null; diff --git a/js/reactionCounter.js b/js/reactionCounter.js index 0dfed685..7bed133a 100644 --- a/js/reactionCounter.js +++ b/js/reactionCounter.js @@ -218,6 +218,75 @@ function notifyHandlers(key) { } } +function subscribeToPointer(pointerInput, handler) { + if (typeof handler !== "function") { + return () => {}; + } + + const canonical = canonicalizePointerForState(pointerInput); + if (!canonical) { + return () => {}; + } + + const { key, pointer } = canonical; + const state = ensurePointerState(key, pointer); + let handlers = pointerHandlers.get(key); + if (!handlers) { + handlers = new Set(); + pointerHandlers.set(key, handlers); + } + handlers.add(handler); + + if (state) { + try { + handler(snapshotState(state)); + } catch (error) { + userLogger.warn("[reactionCounter] Reaction handler threw:", error); + } + } + + return () => { + unsubscribeFromPointer(pointer, handler); + }; +} + +function unsubscribeFromPointer(pointerInput, handler) { + const canonical = canonicalizePointerForState(pointerInput); + if (!canonical) { + return; + } + + const { key } = canonical; + const handlers = pointerHandlers.get(key); + if (!handlers) { + return; + } + + if (handler && typeof handler === "function") { + handlers.delete(handler); + } else { + handlers.clear(); + } + + if (handlers.size === 0) { + pointerHandlers.delete(key); + } +} + +function getPointerSnapshot(pointerInput) { + const canonical = canonicalizePointerForState(pointerInput); + if (!canonical) { + return null; + } + + const state = pointerStates.get(canonical.key); + if (!state) { + return null; + } + + return snapshotState(state); +} + export function ingestLocalReaction({ event, pointer }) { if (!event || !pointer) { return; @@ -353,5 +422,13 @@ export const reactionCounter = { }; reactionCounter.ingestLocalReaction = ingestLocalReaction; +reactionCounter.subscribe = subscribeToPointer; +reactionCounter.unsubscribe = unsubscribeFromPointer; +reactionCounter.getSnapshot = getPointerSnapshot; export default reactionCounter; +export { + subscribeToPointer as subscribeToReactions, + unsubscribeFromPointer as unsubscribeFromReactions, + getPointerSnapshot as getReactionSnapshot, +}; diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 90c19eec..603b3e50 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -61,6 +61,24 @@ export class VideoModal { this.shareBtn = null; this.modalZapBtn = null; this.modalMoreBtn = null; + this.reactionButtons = { + "+": null, + "-": null, + }; + this.reactionMeter = null; + this.reactionMeterFill = null; + this.reactionMeterLabel = null; + this.reactionMeterAssistive = null; + this.reactionCountLabels = { + "+": null, + "-": null, + }; + this.reactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: "", + }; + this.reactionNumberFormatter = null; this.modalAccessibility = null; this.modalNavScrollHandler = null; @@ -104,6 +122,7 @@ export class VideoModal { this.handleShareRequest = this.handleShareRequest.bind(this); this.handleCreatorNavigation = this.handleCreatorNavigation.bind(this); this.handleModalMoreButtonClick = this.handleModalMoreButtonClick.bind(this); + this.handleReactionClick = this.handleReactionClick.bind(this); this.MODAL_LOADING_POSTER = "assets/gif/please-stand-by.gif"; } @@ -295,6 +314,22 @@ export class VideoModal { this.shareBtn = playerModal.querySelector("#shareBtn") || null; this.modalZapBtn = playerModal.querySelector("#modalZapBtn") || null; this.modalMoreBtn = playerModal.querySelector("#modalMoreBtn") || null; + this.reactionButtons["+"] = + playerModal.querySelector("#modalLikeBtn") || null; + this.reactionButtons["-"] = + playerModal.querySelector("#modalDislikeBtn") || null; + this.reactionMeter = + playerModal.querySelector("[data-reaction-meter]") || null; + this.reactionMeterFill = + playerModal.querySelector("[data-reaction-meter-fill]") || null; + this.reactionMeterLabel = + playerModal.querySelector("[data-reaction-meter-label]") || null; + this.reactionMeterAssistive = + playerModal.querySelector("[data-reaction-meter-sr]") || null; + this.reactionCountLabels["+"] = + playerModal.querySelector("[data-reaction-like-count]") || null; + this.reactionCountLabels["-"] = + playerModal.querySelector("[data-reaction-dislike-count]") || null; this.ambientCanvas = playerModal.querySelector("#ambientCanvas") || null; @@ -322,6 +357,14 @@ export class VideoModal { this.setupModalZapPopover(); this.setupModalMorePopover(); + Object.values(this.reactionButtons).forEach((button) => { + if (button) { + button.addEventListener("click", this.handleReactionClick); + } + }); + this.syncReactionButtons(); + this.updateReactionMeterDisplay(); + const closeButton = playerModal.querySelector("#closeModal"); if (closeButton) { closeButton.addEventListener("click", () => { @@ -912,12 +955,54 @@ export class VideoModal { } } + handleReactionClick(event) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + + const button = event?.currentTarget; + if (!button || typeof button !== "object") { + return; + } + + if (button.disabled || button.getAttribute("aria-disabled") === "true") { + return; + } + + const reactionValue = + typeof button.dataset?.reaction === "string" + ? button.dataset.reaction.trim() + : ""; + const normalized = + reactionValue === "+" + ? "+" + : reactionValue === "-" + ? "-" + : ""; + + if (!normalized) { + return; + } + + const currentReaction = this.reactionState?.userReaction || ""; + if (normalized === currentReaction) { + return; + } + + this.dispatch("video:reaction", { + video: this.activeVideo, + reaction: normalized, + previousReaction: currentReaction, + }); + } + open(video, options = {}) { this.activeVideo = video || null; if (!this.playerModal) { return; } + this.resetReactions(); + this.playerModal.classList.remove("hidden"); this.playerModal.removeAttribute("hidden"); this.document.body.classList.add("modal-open"); @@ -949,6 +1034,174 @@ export class VideoModal { this.forceRemovePoster("close"); } + resetReactions() { + this.reactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: "", + }; + this.syncReactionButtons(); + this.updateReactionMeterDisplay(); + } + + setUserReaction(reaction) { + const normalized = reaction === "+" ? "+" : reaction === "-" ? "-" : ""; + if (!this.reactionState) { + this.reactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: normalized, + }; + } else { + this.reactionState.userReaction = normalized; + } + this.syncReactionButtons(); + } + + updateReactionSummary({ total, counts, userReaction } = {}) { + if (!this.reactionState) { + this.reactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: "", + }; + } + + if (counts && typeof counts === "object") { + const nextCounts = { ...this.reactionState.counts }; + for (const [key, value] of Object.entries(counts)) { + nextCounts[key] = this.normalizeReactionCount(value); + } + this.reactionState.counts = nextCounts; + } + + if (Number.isFinite(total)) { + this.reactionState.total = Math.max(0, Number(total)); + } else if (counts && typeof counts === "object") { + let computedTotal = 0; + for (const value of Object.values(this.reactionState.counts)) { + computedTotal += this.normalizeReactionCount(value); + } + this.reactionState.total = computedTotal; + } + + if (userReaction !== undefined) { + this.setUserReaction(userReaction); + } else { + this.syncReactionButtons(); + } + + this.updateReactionMeterDisplay(); + } + + syncReactionButtons() { + const current = this.reactionState?.userReaction || ""; + const likeButton = this.reactionButtons?.["+"] || null; + const dislikeButton = this.reactionButtons?.["-"] || null; + if (likeButton) { + likeButton.setAttribute("aria-pressed", current === "+" ? "true" : "false"); + } + if (dislikeButton) { + dislikeButton.setAttribute("aria-pressed", current === "-" ? "true" : "false"); + } + } + + updateReactionMeterDisplay() { + if (!this.reactionState) { + return; + } + + const counts = this.reactionState.counts || {}; + const likeCount = this.normalizeReactionCount(counts["+"]); + const dislikeCount = this.normalizeReactionCount(counts["-"]); + const totalReactions = likeCount + dislikeCount; + const likeRatio = totalReactions > 0 + ? Math.round((likeCount / totalReactions) * 100) + : 0; + + if (this.reactionCountLabels?.["+"]) { + this.reactionCountLabels["+"].textContent = this.formatReactionCount( + likeCount + ); + } + if (this.reactionCountLabels?.["-"]) { + this.reactionCountLabels["-"].textContent = this.formatReactionCount( + dislikeCount + ); + } + + const meterLabel = + totalReactions > 0 + ? `${likeRatio}% positive` + : "No reactions yet"; + const assistiveLabel = + totalReactions > 0 + ? `${likeRatio}% positive (${this.formatReactionCount( + likeCount + )} likes and ${this.formatReactionCount(dislikeCount)} dislikes)` + : "No reactions yet"; + + if (this.reactionMeter) { + this.reactionMeter.setAttribute("aria-valuenow", String(likeRatio)); + this.reactionMeter.setAttribute("aria-valuetext", assistiveLabel); + } + + if (this.reactionMeterLabel) { + this.reactionMeterLabel.textContent = meterLabel; + } + + if (this.reactionMeterAssistive) { + this.reactionMeterAssistive.textContent = assistiveLabel; + } + + if (this.reactionMeterFill) { + this.reactionMeterFill.style.inlineSize = `${likeRatio}%`; + this.reactionMeterFill.style.width = `${likeRatio}%`; + } + } + + 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; + } + + getReactionNumberFormatter() { + if (this.reactionNumberFormatter === false) { + return null; + } + if (this.reactionNumberFormatter) { + return this.reactionNumberFormatter; + } + try { + if (this.window?.Intl?.NumberFormat) { + this.reactionNumberFormatter = new this.window.Intl.NumberFormat(); + return this.reactionNumberFormatter; + } + } catch (error) { + this.reactionNumberFormatter = false; + return null; + } + this.reactionNumberFormatter = false; + return null; + } + + formatReactionCount(value) { + const numeric = Number.isFinite(value) ? Number(value) : Number(value || 0); + const safeValue = Number.isFinite(numeric) ? Math.max(0, Math.round(numeric)) : 0; + const formatter = this.getReactionNumberFormatter(); + if (formatter) { + try { + return formatter.format(safeValue); + } catch (error) { + // fallback below + } + } + return String(safeValue); + } + applyLoadingPoster() { if (!this.modalVideo) { return;