Align video modal reactions row

This commit is contained in:
thePR0M3TH3AN committed 2025-10-23 10:06:41 -04:00
1 parent 7a20a32bf1
commit 4e108f3e43
5 files changed
+1010 -162

No files matched your search

+254 -161
View File
@@ -85,183 +85,104 @@
</div>
</div>
<div class="video-modal__actions-row flex flex-wrap items-center gap-3">
<button
id="copyMagnetBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-label="Copy magnet"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M17.374 20.235c2.444-2.981 6.626-8.157 6.626-8.157l-3.846-3.092s-2.857 3.523-6.571 8.097c-4.312 5.312-11.881-2.41-6.671-6.671 4.561-3.729 8.097-6.57 8.097-6.57l-3.092-3.842s-5.173 4.181-8.157 6.621c-2.662 2.175-3.76 4.749-3.76 7.24 0 5.254 4.867 10.139 10.121 10.139 2.487 0 5.064-1.095 7.253-3.765zm4.724-7.953l-1.699 2.111-1.74-1.397 1.701-2.114 1.738 1.4zm-10.386-10.385l1.4 1.738-2.113 1.701-1.397-1.74 2.11-1.699z"
/>
</svg>
<span class="video-modal__action-label">Copy magnet</span>
</button>
<div class="popover" data-modal-zap-wrapper>
<button
id="modalZapBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-label="Send a zap"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="modalZapDialog"
>
<div class="video-modal__actions-row flex flex-wrap items-center justify-between gap-3">
<div class="video-modal__reactions flex flex-wrap items-center gap-3 sm:gap-4">
<div class="video-modal__reaction-buttons inline-flex flex-wrap items-center gap-2">
<button
id="modalLikeBtn"
type="button"
class="video-modal__action-button video-modal__reaction-button focus-ring accent-action-button"
data-reaction="+"
aria-pressed="false"
aria-label="Like this video"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<polygon
points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M7 10v12H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z"
/>
<path
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M7 10 11.34 2.67A2 2 0 0 1 13.14 2H14a2 2 0 0 1 2 2v4h3.37a2 2 0 0 1 1.97 2.5l-1.1 5A2 2 0 0 1 18.3 18H11"
/>
</svg>
<span class="video-modal__action-label">Zap</span>
</button>
<span class="video-modal__action-label">Like</span>
<span class="video-modal__reaction-count" data-reaction-like-count>0</span>
</button>
<button
id="modalDislikeBtn"
type="button"
class="video-modal__action-button video-modal__reaction-button focus-ring accent-action-button"
data-reaction="-"
aria-pressed="false"
aria-label="Dislike this video"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M17 14V2h3a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z"
/>
<path
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M17 14 12.66 21.33A2 2 0 0 1 10.86 22H10a2 2 0 0 1-2-2v-4H4.63a2 2 0 0 1-1.97-2.5l1.1-5A2 2 0 0 1 4.73 8H13"
/>
</svg>
<span class="video-modal__action-label">Dislike</span>
<span class="video-modal__reaction-count" data-reaction-dislike-count>0</span>
</button>
</div>
<div
id="modalZapDialog"
class="popover__panel card w-72 max-w-popover-safe px-5 py-4"
data-variant="zap"
role="dialog"
aria-hidden="true"
data-state="closed"
hidden
class="video-modal__reaction-meter"
data-reaction-meter
role="meter"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-valuetext="No reactions yet"
>
<div class="grid gap-4">
<div class="mb-4 flex items-center justify-between gap-4">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
Send a zap
</h3>
<button
type="button"
id="modalZapCloseBtn"
class="focus-ring inline-flex h-8 w-8 items-center justify-center rounded-full text-muted transition hover:text-text"
aria-label="Close zap dialog"
>
✕
</button>
</div>
<p
id="modalZapWalletPrompt"
class="mb-3 text-sm text-muted"
aria-hidden="true"
hidden
>
Connect a wallet in
<button
type="button"
id="modalZapWalletLink"
class="focus-ring inline-flex items-center gap-1 text-sm font-semibold text-info underline decoration-dotted underline-offset-2 hover:text-info-strong"
>
Wallet Connect settings
</button>
to send zaps.
</p>
<form id="modalZapForm" class="space-y-4">
<div class="space-y-2">
<label
for="modalZapAmountInput"
class="text-xs font-semibold uppercase tracking-wide text-muted"
>
Zap amount (sats)
</label>
<input
id="modalZapAmountInput"
type="number"
min="1"
step="1"
inputmode="numeric"
class="input"
placeholder="Enter sats"
/>
</div>
<div class="space-y-2">
<label
for="modalZapCommentInput"
class="text-xs font-semibold uppercase tracking-wide text-muted"
>
Comment (optional)
</label>
<textarea
id="modalZapCommentInput"
rows="2"
maxlength="280"
class="input resize-y"
placeholder="Include a note with your zap"
></textarea>
</div>
<p
id="modalZapSplitSummary"
class="text-sm text-muted"
aria-live="polite"
>
Enter an amount to view the split.
</p>
<p
id="modalZapStatus"
class="text-xs text-muted"
role="status"
aria-live="polite"
></p>
<ul
id="modalZapReceipts"
class="space-y-2 text-xs text-muted"
></ul>
<div class="flex items-center justify-end gap-2">
<button
type="submit"
id="modalZapSendBtn"
class="btn"
>
Send
</button>
</div>
</form>
<span class="video-modal__reaction-meter-label" data-reaction-meter-label>No reactions yet</span>
<div class="video-modal__reaction-meter-track" aria-hidden="true">
<div
class="video-modal__reaction-meter-fill"
data-reaction-meter-fill
style="width: 0%"
></div>
</div>
<span class="sr-only" data-reaction-meter-sr>No reactions yet</span>
</div>
</div>
<button
id="shareBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-label="Share video"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M5 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm11.122 12.065A4 4 0 1 1 24 20a4 4 0 0 1-7.878-.935l-5.488-2.927a6.009 6.009 0 0 1-.943 1.764l5.488 2.927ZM24 4a4 4 0 1 1-7.878.935l-5.488 2.927a6.01 6.01 0 0 1-.943-1.764l5.488-2.927A4 4 0 0 1 24 4Z"
/>
</svg>
<span class="video-modal__action-label">Share</span>
</button>
<div class="relative" data-more-menu-wrapper="true">
<div class="video-modal__actions-group flex flex-wrap items-center gap-3">
<button
id="modalMoreBtn"
id="copyMagnetBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-haspopup="true"
aria-expanded="false"
aria-label="More actions"
data-more-dropdown="modal"
aria-label="Copy magnet"
>
<svg
class="video-modal__action-icon icon-image"
@@ -269,12 +190,184 @@
aria-hidden="true"
focusable="false"
>
<circle cx="5" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="19" cy="12" r="2" fill="currentColor" />
<path
fill="currentColor"
d="M17.374 20.235c2.444-2.981 6.626-8.157 6.626-8.157l-3.846-3.092s-2.857 3.523-6.571 8.097c-4.312 5.312-11.881-2.41-6.671-6.671 4.561-3.729 8.097-6.57 8.097-6.57l-3.092-3.842s-5.173 4.181-8.157 6.621c-2.662 2.175-3.76 4.749-3.76 7.24 0 5.254 4.867 10.139 10.121 10.139 2.487 0 5.064-1.095 7.253-3.765zm4.724-7.953l-1.699 2.111-1.74-1.397 1.701-2.114 1.738 1.4zm-10.386-10.385l1.4 1.738-2.113 1.701-1.397-1.74 2.11-1.699z"
/>
</svg>
<span class="video-modal__action-label">More</span>
<span class="video-modal__action-label">Copy magnet</span>
</button>
<div class="popover" data-modal-zap-wrapper>
<button
id="modalZapBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-label="Send a zap"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="modalZapDialog"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<polygon
points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="video-modal__action-label">Zap</span>
</button>
<div
id="modalZapDialog"
class="popover__panel card w-72 max-w-popover-safe px-5 py-4"
data-variant="zap"
role="dialog"
aria-hidden="true"
data-state="closed"
hidden
>
<div class="grid gap-4">
<div class="mb-4 flex items-center justify-between gap-4">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
Send a zap
</h3>
<button
type="button"
id="modalZapCloseBtn"
class="focus-ring inline-flex h-8 w-8 items-center justify-center rounded-full text-muted transition hover:text-text"
aria-label="Close zap dialog"
>
✕
</button>
</div>
<p
id="modalZapWalletPrompt"
class="mb-3 text-sm text-muted"
aria-hidden="true"
hidden
>
Connect a wallet in
<button
type="button"
id="modalZapWalletLink"
class="focus-ring inline-flex items-center gap-1 text-sm font-semibold text-info underline decoration-dotted underline-offset-2 hover:text-info-strong"
>
Wallet Connect settings
</button>
to send zaps.
</p>
<form id="modalZapForm" class="space-y-4">
<div class="space-y-2">
<label
for="modalZapAmountInput"
class="text-xs font-semibold uppercase tracking-wide text-muted"
>
Zap amount (sats)
</label>
<input
id="modalZapAmountInput"
type="number"
min="1"
step="1"
inputmode="numeric"
class="input"
placeholder="Enter sats"
/>
</div>
<div class="space-y-2">
<label
for="modalZapCommentInput"
class="text-xs font-semibold uppercase tracking-wide text-muted"
>
Comment (optional)
</label>
<textarea
id="modalZapCommentInput"
rows="2"
maxlength="280"
class="input resize-y"
placeholder="Include a note with your zap"
></textarea>
</div>
<p
id="modalZapSplitSummary"
class="text-sm text-muted"
aria-live="polite"
>
Enter an amount to view the split.
</p>
<p
id="modalZapStatus"
class="text-xs text-muted"
role="status"
aria-live="polite"
></p>
<ul
id="modalZapReceipts"
class="space-y-2 text-xs text-muted"
></ul>
<div class="flex items-center justify-end gap-2">
<button
type="submit"
id="modalZapSendBtn"
class="btn"
>
Send
</button>
</div>
</form>
</div>
</div>
</div>
<button
id="shareBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-label="Share video"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M5 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm11.122 12.065A4 4 0 1 1 24 20a4 4 0 0 1-7.878-.935l-5.488-2.927a6.009 6.009 0 0 1-.943 1.764l5.488 2.927ZM24 4a4 4 0 1 1-7.878.935l-5.488 2.927a6.01 6.01 0 0 1-.943-1.764l5.488-2.927A4 4 0 0 1 24 4Z"
/>
</svg>
<span class="video-modal__action-label">Share</span>
</button>
<div class="relative" data-more-menu-wrapper="true">
<button
id="modalMoreBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-haspopup="true"
aria-expanded="false"
aria-label="More actions"
data-more-dropdown="modal"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<circle cx="5" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="19" cy="12" r="2" fill="currentColor" />
</svg>
<span class="video-modal__action-label">More</span>
</button>
</div>
</div>
</div>
+81 -1
View File
@@ -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 {
+345
View File
@@ -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;
+77
View File
@@ -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,
};
+253
View File
@@ -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;