mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 10:52:27 +00:00
feat: overlay moderation badge on blurred video thumbnails
Moves the moderation warning badge ("Content blocked...") to overlay the blurred thumbnail image instead of sitting below it or increasing card height. This logic is applied to both the main `VideoCard` and the `SimilarContentCard` to ensure layout consistency.
Changes:
- VideoCard.js: Refactor `updateModerationBadge` to append the badge container as a child of the `anchorEl` (thumbnail wrapper) with absolute positioning.
- SimilarContentCard.js: Implement `refreshModerationUi` logic to generate and overlay a simplified moderation badge on the thumbnail when `trustedMuted` context is present.
- Fix tests/moderation/video-card.test.mjs: Patch test environment to mock `globalThis.navigator`.
This commit is contained in:
1 parent
023c97340b
commit
e77418f0a8
3 files changed
+153
-14
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||||
import { formatShortNpub } from "../../utils/formatters.js";
|
||||
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
|
||||
import { normalizeVideoModerationContext } from "../moderationUiHelpers.js";
|
||||
import { buildModerationBadgeText } from "../moderationCopy.js";
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
|
||||
@@ -69,6 +71,10 @@ export class SimilarContentCard {
|
||||
this.viewCountEl = null;
|
||||
this.discussionCountEl = null;
|
||||
|
||||
this.moderationBadgeEl = null;
|
||||
this.moderationBadgeLabelEl = null;
|
||||
this.moderationBadgeTextEl = null;
|
||||
|
||||
this.build();
|
||||
}
|
||||
|
||||
@@ -342,6 +348,7 @@ export class SimilarContentCard {
|
||||
root.appendChild(content);
|
||||
}
|
||||
|
||||
this.refreshModerationUi();
|
||||
this.applyPointerDatasets();
|
||||
this.bindEvents();
|
||||
}
|
||||
@@ -361,6 +368,89 @@ export class SimilarContentCard {
|
||||
return anchor;
|
||||
}
|
||||
|
||||
getModerationContext() {
|
||||
return normalizeVideoModerationContext(this.video?.moderation);
|
||||
}
|
||||
|
||||
buildModerationBadge(context = this.getModerationContext()) {
|
||||
if (!context.shouldShow || !context.trustedMuted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const badge = this.document.createElement("div");
|
||||
badge.className = "moderation-badge opacity-95";
|
||||
badge.dataset.variant = "warning";
|
||||
badge.dataset.moderationBadge = "true";
|
||||
badge.dataset.moderationState = "trusted-mute";
|
||||
|
||||
// Position it centrally over the thumbnail like in VideoCard
|
||||
badge.style.pointerEvents = "auto";
|
||||
|
||||
const label = this.document.createElement("span");
|
||||
label.className = "moderation-badge__label inline-flex items-center gap-xs";
|
||||
|
||||
// Simplified icon for small card
|
||||
const iconWrapper = this.document.createElement("span");
|
||||
iconWrapper.className = "moderation-badge__icon";
|
||||
iconWrapper.setAttribute("aria-hidden", "true");
|
||||
|
||||
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 20 20");
|
||||
svg.setAttribute("focusable", "false");
|
||||
svg.classList.add("moderation-badge__icon-mark");
|
||||
|
||||
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
|
||||
path.setAttribute("fill", "currentColor");
|
||||
path.setAttribute("d", "M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z");
|
||||
path.setAttribute("fill-rule", "evenodd");
|
||||
path.setAttribute("clip-rule", "evenodd");
|
||||
|
||||
svg.appendChild(path);
|
||||
iconWrapper.appendChild(svg);
|
||||
label.appendChild(iconWrapper);
|
||||
|
||||
const text = this.document.createElement("span");
|
||||
text.className = "moderation-badge__text";
|
||||
// Shorten text for small cards if needed, but keeping standard for now
|
||||
text.textContent = "Blocked by trusted contact";
|
||||
|
||||
label.appendChild(text);
|
||||
badge.appendChild(label);
|
||||
|
||||
this.moderationBadgeEl = badge;
|
||||
return badge;
|
||||
}
|
||||
|
||||
refreshModerationUi() {
|
||||
const context = this.getModerationContext();
|
||||
|
||||
if (this.moderationBadgeEl) {
|
||||
this.moderationBadgeEl.remove();
|
||||
this.moderationBadgeEl = null;
|
||||
}
|
||||
|
||||
// Check specifically for muted/blurred state where we want the overlay
|
||||
if (context.shouldShow && context.trustedMuted && !context.overrideActive) {
|
||||
// Ensure media link exists
|
||||
if (!this.mediaLinkEl) return;
|
||||
|
||||
const badge = this.buildModerationBadge(context);
|
||||
if (badge) {
|
||||
// Create a wrapper for centering if needed, or use classes on badge if possible.
|
||||
// Similar to VideoCard, let's create a container for absolute positioning
|
||||
const container = this.document.createElement("div");
|
||||
container.className = "absolute inset-0 flex items-center justify-center p-2 z-10 pointer-events-none";
|
||||
container.appendChild(badge);
|
||||
|
||||
this.mediaLinkEl.appendChild(container);
|
||||
// We track the container removal via moderationBadgeEl reference if we wanted strict cleanup,
|
||||
// but for now just rebuilding is fine if we store the container.
|
||||
// Let's store badge el as the container actually to make removal easier
|
||||
this.moderationBadgeEl = container;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildThumbnail() {
|
||||
const img = this.document.createElement("img");
|
||||
img.decoding = "async";
|
||||
|
||||
@@ -493,14 +493,28 @@ export class VideoCard {
|
||||
}
|
||||
|
||||
const moderationBadgeSlot = this.createElement("div", {
|
||||
classNames: ["video-card__moderation-slot", "px-md", "pt-md"],
|
||||
classNames: [
|
||||
"video-card__moderation-overlay",
|
||||
"absolute",
|
||||
"inset-0",
|
||||
"flex",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
"p-4",
|
||||
"z-10",
|
||||
"pointer-events-none",
|
||||
],
|
||||
});
|
||||
moderationBadgeSlot.hidden = true;
|
||||
moderationBadgeSlot.setAttribute("aria-hidden", "true");
|
||||
this.moderationBadgeSlot = moderationBadgeSlot;
|
||||
|
||||
// Append slot to anchor (media wrapper) instead of root to overlay thumbnail
|
||||
if (this.anchorEl) {
|
||||
this.anchorEl.appendChild(moderationBadgeSlot);
|
||||
}
|
||||
|
||||
root.appendChild(anchor);
|
||||
root.appendChild(moderationBadgeSlot);
|
||||
root.appendChild(content);
|
||||
|
||||
this.applyPlaybackDatasets();
|
||||
@@ -1632,18 +1646,24 @@ export class VideoCard {
|
||||
let slot = this.moderationBadgeSlot;
|
||||
const hiddenActive = context.activeHidden && !context.overrideActive;
|
||||
|
||||
if (!slot && this.root) {
|
||||
if (!slot && this.anchorEl) {
|
||||
slot = this.createElement("div", {
|
||||
classNames: ["video-card__moderation-slot", "px-md", "pt-md"],
|
||||
classNames: [
|
||||
"video-card__moderation-overlay",
|
||||
"absolute",
|
||||
"inset-0",
|
||||
"flex",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
"p-4",
|
||||
"z-10",
|
||||
"pointer-events-none",
|
||||
],
|
||||
});
|
||||
slot.hidden = true;
|
||||
slot.setAttribute("aria-hidden", "true");
|
||||
this.moderationBadgeSlot = slot;
|
||||
if (this.anchorEl && this.anchorEl.parentElement === this.root) {
|
||||
this.root.insertBefore(slot, this.anchorEl.nextSibling);
|
||||
} else {
|
||||
this.root.appendChild(slot);
|
||||
}
|
||||
this.anchorEl.appendChild(slot);
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
@@ -1724,6 +1744,8 @@ export class VideoCard {
|
||||
if (!badge) {
|
||||
const nextBadge = this.buildModerationBadge(context);
|
||||
if (nextBadge && slot) {
|
||||
nextBadge.style.pointerEvents = "auto"; // Enable interaction on badge itself
|
||||
nextBadge.classList.add("opacity-95"); // Slight transparency
|
||||
slot.appendChild(nextBadge);
|
||||
slot.hidden = false;
|
||||
slot.removeAttribute("aria-hidden");
|
||||
@@ -1732,6 +1754,8 @@ export class VideoCard {
|
||||
return;
|
||||
}
|
||||
|
||||
badge.style.pointerEvents = "auto";
|
||||
badge.classList.add("opacity-95");
|
||||
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
|
||||
const state = context.overrideActive
|
||||
? "override"
|
||||
@@ -1985,9 +2009,17 @@ export class VideoCard {
|
||||
this.contentEl.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
if (badgeSlot && this.moderationBadgeEl) {
|
||||
badgeSlot.hidden = false;
|
||||
badgeSlot.removeAttribute("aria-hidden");
|
||||
// In hidden state (blocked), the anchor/thumbnail is hidden, so overlay slot
|
||||
// won't be visible. We rely on the summary container below.
|
||||
// We hide the badge slot here because we don't want it floating if anchor is hidden
|
||||
// (though usually anchor hidden means children hidden too).
|
||||
// However, if we move badgeSlot to be a child of anchorEl, it will be hidden automatically.
|
||||
|
||||
if (badgeSlot) {
|
||||
// If it's a child of anchorEl, it's hidden by parent.
|
||||
// We can force it hidden to be safe.
|
||||
badgeSlot.hidden = true;
|
||||
badgeSlot.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
const summaryContainer = this.ensureHiddenSummaryContainer();
|
||||
@@ -2008,7 +2040,11 @@ export class VideoCard {
|
||||
this.contentEl.removeAttribute("hidden");
|
||||
this.contentEl.removeAttribute("aria-hidden");
|
||||
}
|
||||
if (badgeSlot && !this.moderationBadgeEl) {
|
||||
// Restore badge slot visibility if badge exists and we are not hidden
|
||||
if (badgeSlot && this.moderationBadgeEl) {
|
||||
badgeSlot.hidden = false;
|
||||
badgeSlot.removeAttribute("aria-hidden");
|
||||
} else if (badgeSlot) {
|
||||
badgeSlot.hidden = true;
|
||||
badgeSlot.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ function setupDom(t) {
|
||||
"ResizeObserver",
|
||||
"requestAnimationFrame",
|
||||
"cancelAnimationFrame",
|
||||
"navigator",
|
||||
];
|
||||
|
||||
keys.forEach((key) => {
|
||||
@@ -55,6 +54,14 @@ function setupDom(t) {
|
||||
globalThis[key] = window[key];
|
||||
});
|
||||
|
||||
// Mock navigator safely
|
||||
const originalNavigator = globalThis.navigator;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: window.navigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
if (!window.requestAnimationFrame) {
|
||||
window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16);
|
||||
globalThis.requestAnimationFrame = window.requestAnimationFrame;
|
||||
@@ -85,6 +92,12 @@ function setupDom(t) {
|
||||
globalThis[key] = previousValue;
|
||||
}
|
||||
});
|
||||
// Restore navigator
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
return { window, document: window.document };
|
||||
|
||||
Reference in new issue
Block a user