+
-
-
-
- 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..42707260 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,209 @@ 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.");
+ }
+ }
+
syncProfileModalState({
includeSavedProfiles = true,
includeActivePubkey = true,
@@ -3872,6 +4208,7 @@ class Application {
this.playbackService.cleanupWatchdog();
}
this.teardownModalViewCountSubscription();
+ this.teardownModalReactionSubscription();
if (!preserveObservers && this.mediaLoader) {
this.mediaLoader.disconnect();
@@ -4438,6 +4775,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 +7799,10 @@ class Application {
this.currentVideoPointer,
this.currentVideoPointerKey
);
+ this.subscribeModalReactions(
+ this.currentVideoPointer,
+ this.currentVideoPointerKey
+ );
this.syncModalMoreMenuData();
@@ -7767,6 +8109,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 +8224,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..102ed6a3 100644
--- a/js/reactionCounter.js
+++ b/js/reactionCounter.js
@@ -1,9 +1,15 @@
import { publishVideoReaction } from "./nostr.js";
+import { nostrClient } from "./nostrClientFacade.js";
import { normalizePointerInput, pointerKey } from "./nostr/watchHistory.js";
import { devLogger, userLogger } from "./utils/logger.js";
const pointerStates = new Map();
const pointerHandlers = new Map();
+const pointerHydrationPromises = new Map();
+const pointerSubscriptions = new Map();
+
+const REACTION_EVENT_KIND = 7;
+const REACTION_HYDRATION_LIMIT = 400;
function clonePointerDescriptor(pointer) {
if (!pointer || typeof pointer !== "object") {
@@ -43,6 +49,9 @@ function ensurePointerState(key, pointer) {
totals: new Map(),
reactionsByPubkey: new Map(),
lastUpdatedAt: 0,
+ latestCreatedAt: 0,
+ lastSyncedAt: 0,
+ hydrated: false,
};
pointerStates.set(key, state);
return state;
@@ -164,6 +173,12 @@ function applyReactionToState(key, pointer, event) {
) {
state.reactionsByPubkey.set(normalized.pubkey, normalized);
state.lastUpdatedAt = Date.now();
+ if (Number.isFinite(normalized.created_at)) {
+ const created = Math.max(0, Math.floor(normalized.created_at));
+ if (!Number.isFinite(state.latestCreatedAt) || created > state.latestCreatedAt) {
+ state.latestCreatedAt = created;
+ }
+ }
if (!changed) {
changed = true;
}
@@ -187,12 +202,18 @@ function snapshotState(state) {
eventId: record.eventId,
};
}
+ const latestCreatedAt = Number.isFinite(state.latestCreatedAt)
+ ? Math.max(0, Math.floor(state.latestCreatedAt))
+ : 0;
return {
pointer: state.pointer ? { ...state.pointer } : null,
total,
counts,
reactions,
lastUpdatedAt: state.lastUpdatedAt,
+ latestCreatedAt,
+ lastSyncedAt: state.lastSyncedAt,
+ hydrated: Boolean(state.hydrated),
};
}
@@ -218,6 +239,131 @@ 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);
+ }
+ }
+
+ const hydrationPromise = ensureReactionHydration(key, pointer, state);
+ if (hydrationPromise && typeof hydrationPromise.then === "function") {
+ hydrationPromise
+ .catch((error) => {
+ devLogger.warn(
+ "[reactionCounter] Failed to hydrate reaction history:",
+ error,
+ );
+ })
+ .finally(() => {
+ if (hasHandlers(key)) {
+ ensureReactionLiveSubscription(key, pointer);
+ }
+ });
+ } else {
+ const liveToken = ensureReactionLiveSubscription(key, pointer);
+ if (
+ !liveToken &&
+ !hydrationPromise &&
+ !nostrClient?.pool &&
+ typeof nostrClient?.ensurePool === "function"
+ ) {
+ Promise.resolve()
+ .then(() => nostrClient.ensurePool())
+ .then(() => {
+ if (!hasHandlers(key)) {
+ return;
+ }
+ const followUp = ensureReactionHydration(key, pointer, state);
+ if (followUp && typeof followUp.then === "function") {
+ followUp
+ .catch((error) => {
+ devLogger.warn(
+ "[reactionCounter] Failed to hydrate reaction history after pool init:",
+ error,
+ );
+ })
+ .finally(() => {
+ if (hasHandlers(key)) {
+ ensureReactionLiveSubscription(key, pointer);
+ }
+ });
+ } else if (hasHandlers(key)) {
+ ensureReactionLiveSubscription(key, pointer);
+ }
+ })
+ .catch((error) => {
+ devLogger.warn(
+ "[reactionCounter] Failed to initialize nostr pool for reactions:",
+ 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);
+ teardownLiveSubscription(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;
@@ -236,6 +382,291 @@ export function ingestLocalReaction({ event, pointer }) {
}
}
+function hasHandlers(key) {
+ const handlers = pointerHandlers.get(key);
+ return Boolean(handlers && handlers.size);
+}
+
+function getPointerSignature(pointer) {
+ if (!pointer || typeof pointer !== "object") {
+ return "";
+ }
+ const type = pointer.type === "a" ? "a" : pointer.type === "e" ? "e" : "";
+ const value =
+ typeof pointer.value === "string" && pointer.value.trim()
+ ? pointer.value.trim()
+ : "";
+ if (!type || !value) {
+ return "";
+ }
+ const relay =
+ typeof pointer.relay === "string" && pointer.relay.trim()
+ ? pointer.relay.trim()
+ : "";
+ return `${type}:${value}:${relay}`;
+}
+
+function gatherRelayCandidates(pointer) {
+ const relays = [];
+ const seen = new Set();
+
+ const addRelay = (candidate) => {
+ if (typeof candidate !== "string") {
+ return;
+ }
+ const trimmed = candidate.trim();
+ if (!trimmed || seen.has(trimmed)) {
+ return;
+ }
+ seen.add(trimmed);
+ relays.push(trimmed);
+ };
+
+ if (pointer && typeof pointer.relay === "string") {
+ addRelay(pointer.relay);
+ }
+
+ const client = nostrClient || null;
+ const candidateLists = [client?.readRelays, client?.relays];
+
+ for (const list of candidateLists) {
+ if (!list) {
+ continue;
+ }
+ if (Array.isArray(list)) {
+ for (const relay of list) {
+ addRelay(relay);
+ }
+ } else if (list instanceof Set) {
+ for (const relay of list) {
+ addRelay(relay);
+ }
+ }
+ }
+
+ return relays;
+}
+
+function buildReactionFilters(pointer, { limit, since } = {}) {
+ if (!pointer || typeof pointer !== "object") {
+ return [];
+ }
+
+ const type = pointer.type === "a" ? "a" : pointer.type === "e" ? "e" : "";
+ const value =
+ typeof pointer.value === "string" && pointer.value.trim()
+ ? pointer.value.trim()
+ : "";
+ if (!type || !value) {
+ return [];
+ }
+
+ const filter = { kinds: [REACTION_EVENT_KIND] };
+
+ if (type === "a") {
+ filter["#a"] = [value];
+ } else {
+ filter["#e"] = [value];
+ }
+
+ if (Number.isFinite(limit) && limit > 0) {
+ filter.limit = Math.max(1, Math.floor(limit));
+ }
+
+ if (Number.isFinite(since) && since >= 0) {
+ filter.since = Math.max(0, Math.floor(since));
+ }
+
+ return [filter];
+}
+
+function buildPointerQuery(pointer, options = {}) {
+ const client = nostrClient || null;
+ const pool = client?.pool || null;
+ if (!pool || typeof pool.list !== "function") {
+ return null;
+ }
+
+ const relays = gatherRelayCandidates(pointer);
+ if (!relays.length) {
+ return null;
+ }
+
+ const filters = buildReactionFilters(pointer, options);
+ if (!filters.length) {
+ return null;
+ }
+
+ return { pool, relays, filters };
+}
+
+async function hydratePointerState(key, pointer, state) {
+ const query = buildPointerQuery(pointer, { limit: REACTION_HYDRATION_LIMIT });
+ if (!query) {
+ return null;
+ }
+
+ let events = [];
+ try {
+ events = await query.pool.list(query.relays, query.filters);
+ } catch (error) {
+ throw error;
+ }
+
+ const eventList = Array.isArray(events) ? events : [];
+ const ordered = eventList
+ .filter((event) => event && typeof event === "object")
+ .sort((a, b) => {
+ const aTime = Number.isFinite(a?.created_at) ? Number(a.created_at) : 0;
+ const bTime = Number.isFinite(b?.created_at) ? Number(b.created_at) : 0;
+ return aTime - bTime;
+ });
+
+ let changed = false;
+ for (const event of ordered) {
+ if (applyReactionToState(key, pointer, event)) {
+ changed = true;
+ }
+ }
+
+ state.lastSyncedAt = Date.now();
+ state.hydrated = true;
+
+ if (changed && hasHandlers(key)) {
+ notifyHandlers(key);
+ } else if (!ordered.length && hasHandlers(key)) {
+ notifyHandlers(key);
+ }
+
+ return null;
+}
+
+function ensureReactionHydration(key, pointer, state) {
+ if (!state || state.hydrated) {
+ return null;
+ }
+
+ if (pointerHydrationPromises.has(key)) {
+ return pointerHydrationPromises.get(key);
+ }
+
+ const query = buildPointerQuery(pointer, { limit: REACTION_HYDRATION_LIMIT });
+ if (!query) {
+ return null;
+ }
+
+ const promise = hydratePointerState(key, pointer, state)
+ .catch((error) => {
+ devLogger.warn("[reactionCounter] Failed to hydrate reaction history:", error);
+ })
+ .finally(() => {
+ pointerHydrationPromises.delete(key);
+ });
+
+ pointerHydrationPromises.set(key, promise);
+ return promise;
+}
+
+function teardownLiveSubscription(key) {
+ const existing = pointerSubscriptions.get(key);
+ if (!existing) {
+ return;
+ }
+ pointerSubscriptions.delete(key);
+ const unsub = existing.unsub;
+ if (typeof unsub === "function") {
+ try {
+ unsub();
+ } catch (error) {
+ devLogger.warn("[reactionCounter] Failed to tear down reaction stream:", error);
+ }
+ }
+}
+
+function ensureReactionLiveSubscription(key, pointer) {
+ const signature = getPointerSignature(pointer);
+ if (!signature) {
+ teardownLiveSubscription(key);
+ return null;
+ }
+
+ const existing = pointerSubscriptions.get(key);
+ if (existing && existing.signature === signature) {
+ return existing.unsub;
+ }
+
+ teardownLiveSubscription(key);
+
+ const query = buildPointerQuery(pointer, {});
+ if (!query || typeof query.pool.sub !== "function") {
+ return null;
+ }
+
+ const state = pointerStates.get(key);
+ const sinceSeconds =
+ Number.isFinite(state?.latestCreatedAt) && state.latestCreatedAt > 0
+ ? Math.max(0, Math.floor(state.latestCreatedAt))
+ : null;
+ if (Number.isFinite(sinceSeconds)) {
+ for (const filter of query.filters) {
+ if (filter && typeof filter === "object") {
+ filter.since = sinceSeconds;
+ }
+ }
+ }
+
+ let subscription;
+ try {
+ subscription = query.pool.sub(query.relays, query.filters);
+ } catch (error) {
+ devLogger.warn("[reactionCounter] Failed to subscribe to reaction events:", error);
+ return null;
+ }
+
+ const handleEvent = (event) => {
+ if (!event) {
+ return;
+ }
+ try {
+ const changed = applyReactionToState(key, pointer, event);
+ if (changed && hasHandlers(key)) {
+ notifyHandlers(key);
+ }
+ } catch (error) {
+ devLogger.warn("[reactionCounter] Reaction event handler failed:", error);
+ }
+ };
+
+ if (subscription && typeof subscription.on === "function") {
+ subscription.on("event", handleEvent);
+ }
+
+ const originalUnsub =
+ subscription && typeof subscription.unsub === "function"
+ ? subscription.unsub.bind(subscription)
+ : null;
+
+ const unsubscribe = () => {
+ if (originalUnsub) {
+ try {
+ originalUnsub();
+ } catch (error) {
+ devLogger.warn(
+ "[reactionCounter] Failed to unsubscribe from reaction events:",
+ error
+ );
+ }
+ }
+ };
+
+ pointerSubscriptions.set(key, {
+ signature,
+ unsub: unsubscribe,
+ });
+
+ return unsubscribe;
+}
+
export const reactionCounter = {
async publish(pointer, options = {}) {
try {
@@ -353,5 +784,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;