+
-
-
-
- Send a zap
-
-
-
-
- Connect a wallet in
-
- to send zaps.
-
-
+
No reactions yet
+
+
No reactions yet
-
-
+
+
+
+
+
+
+
+ Send a zap
+
+
+
+
+ Connect a wallet in
+
+ to send zaps.
+
+
+
+
+
+
+
+
+
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;