mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 00:42:30 +00:00
Add moderation overrides to video cards
This commit is contained in:
1 parent
08916a8f0d
commit
0ec7c5e76c
6 files changed
+1004
-29
No files matched your search
@@ -112,6 +112,9 @@ import {
|
||||
storeUrlHealth as persistUrlHealth,
|
||||
setInFlightUrlProbe,
|
||||
getInFlightUrlProbe,
|
||||
getModerationOverride,
|
||||
setModerationOverride,
|
||||
loadModerationOverridesFromStorage,
|
||||
URL_PROBE_TIMEOUT_MS,
|
||||
urlHealthConstants,
|
||||
} from "./state/cache.js";
|
||||
@@ -942,6 +945,10 @@ class Application {
|
||||
};
|
||||
this.videoListView.setDeleteHandler(this.videoListViewDeleteHandler);
|
||||
|
||||
this.videoListView.setModerationOverrideHandler((detail = {}) =>
|
||||
this.handleModerationOverride(detail)
|
||||
);
|
||||
|
||||
if (this.moreMenuController) {
|
||||
this.moreMenuController.attachVideoListView(this.videoListView);
|
||||
}
|
||||
@@ -1095,6 +1102,8 @@ class Application {
|
||||
this.authService.hydrateFromStorage();
|
||||
this.renderSavedProfiles();
|
||||
|
||||
loadModerationOverridesFromStorage();
|
||||
|
||||
const videoModalPromise = this.videoModal.load().then(() => {
|
||||
const modalRoot = this.videoModal.getRoot();
|
||||
if (modalRoot) {
|
||||
@@ -4379,9 +4388,9 @@ class Application {
|
||||
if (Array.isArray(payload.videos)) {
|
||||
videos = payload.videos;
|
||||
}
|
||||
if (payload.metadata && typeof payload.metadata === "object") {
|
||||
metadata = { ...payload.metadata };
|
||||
}
|
||||
if (payload.metadata && typeof payload.metadata === "object") {
|
||||
metadata = { ...payload.metadata };
|
||||
}
|
||||
}
|
||||
|
||||
this.latestFeedMetadata = metadata;
|
||||
@@ -4389,7 +4398,11 @@ class Application {
|
||||
this.videoListView.state.feedMetadata = metadata;
|
||||
}
|
||||
|
||||
this.videoListView.render(videos, metadata);
|
||||
const decoratedVideos = Array.isArray(videos)
|
||||
? videos.map((video) => this.decorateVideoModeration(video))
|
||||
: [];
|
||||
|
||||
this.videoListView.render(decoratedVideos, metadata);
|
||||
}
|
||||
|
||||
refreshVideoDiscussionCounts(videos = [], options = {}) {
|
||||
@@ -4405,6 +4418,247 @@ class Application {
|
||||
});
|
||||
}
|
||||
|
||||
deriveModerationReportType(summary) {
|
||||
if (!summary || typeof summary !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const types = summary.types && typeof summary.types === "object" ? summary.types : null;
|
||||
if (!types) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let bestType = "";
|
||||
let bestScore = -1;
|
||||
for (const [type, stats] of Object.entries(types)) {
|
||||
if (!stats || typeof stats !== "object") {
|
||||
continue;
|
||||
}
|
||||
const trusted = Number.isFinite(stats.trusted) ? Math.floor(stats.trusted) : 0;
|
||||
if (trusted > bestScore) {
|
||||
bestScore = trusted;
|
||||
bestType = typeof type === "string" ? type : bestType;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof bestType === "string" ? bestType.toLowerCase() : "";
|
||||
}
|
||||
|
||||
deriveModerationTrustedCount(summary, reportType) {
|
||||
if (!summary || typeof summary !== "object") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalizedType = typeof reportType === "string" ? reportType.toLowerCase() : "";
|
||||
const types = summary.types && typeof summary.types === "object" ? summary.types : {};
|
||||
|
||||
if (normalizedType && types[normalizedType]) {
|
||||
const entry = types[normalizedType];
|
||||
if (entry && Number.isFinite(entry.trusted)) {
|
||||
return Math.max(0, Math.floor(entry.trusted));
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(summary.totalTrusted)) {
|
||||
return Math.max(0, Math.floor(summary.totalTrusted));
|
||||
}
|
||||
|
||||
for (const stats of Object.values(types)) {
|
||||
if (stats && Number.isFinite(stats.trusted)) {
|
||||
return Math.max(0, Math.floor(stats.trusted));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
getReporterDisplayName(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cachedProfile = this.getProfileCacheEntry(trimmed);
|
||||
const cachedName = cachedProfile?.profile?.name;
|
||||
if (typeof cachedName === "string" && cachedName.trim()) {
|
||||
return cachedName.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.NostrTools?.nip19?.npubEncode) {
|
||||
const encoded = window.NostrTools.nip19.npubEncode(trimmed);
|
||||
if (encoded && typeof encoded === "string") {
|
||||
return `${encoded.slice(0, 8)}…${encoded.slice(-4)}`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[Application] Failed to encode reporter npub", error);
|
||||
}
|
||||
|
||||
if (trimmed.length > 12) {
|
||||
return `${trimmed.slice(0, 8)}…${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
decorateVideoModeration(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return video;
|
||||
}
|
||||
|
||||
const existingModeration =
|
||||
video.moderation && typeof video.moderation === "object"
|
||||
? { ...video.moderation }
|
||||
: {};
|
||||
|
||||
const summary =
|
||||
existingModeration.summary && typeof existingModeration.summary === "object"
|
||||
? existingModeration.summary
|
||||
: null;
|
||||
|
||||
const rawReportType =
|
||||
typeof existingModeration.reportType === "string" &&
|
||||
existingModeration.reportType.trim()
|
||||
? existingModeration.reportType.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
const reportType = rawReportType || this.deriveModerationReportType(summary) || "";
|
||||
|
||||
const sanitizedReporters = Array.isArray(existingModeration.trustedReporters)
|
||||
? existingModeration.trustedReporters
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const pubkey =
|
||||
typeof entry.pubkey === "string" ? entry.pubkey.trim().toLowerCase() : "";
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
const latest = Number.isFinite(entry.latest)
|
||||
? Math.floor(entry.latest)
|
||||
: 0;
|
||||
return { pubkey, latest };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const reporterPubkeys = sanitizedReporters.map((entry) => entry.pubkey);
|
||||
|
||||
const reporterDisplayNames = [];
|
||||
const seenNames = new Set();
|
||||
for (const reporterPubkey of reporterPubkeys) {
|
||||
const name = this.getReporterDisplayName(reporterPubkey);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizedName.toLowerCase();
|
||||
if (seenNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(key);
|
||||
reporterDisplayNames.push(normalizedName);
|
||||
}
|
||||
|
||||
const trustedCount = Number.isFinite(existingModeration.trustedCount)
|
||||
? Math.max(0, Math.floor(existingModeration.trustedCount))
|
||||
: this.deriveModerationTrustedCount(summary, reportType);
|
||||
|
||||
const overrideEntry = getModerationOverride(video.id);
|
||||
const overrideActive = overrideEntry?.showAnyway === true;
|
||||
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
|
||||
? Math.floor(overrideEntry.updatedAt)
|
||||
: Date.now();
|
||||
|
||||
const originalState =
|
||||
existingModeration.original && typeof existingModeration.original === "object"
|
||||
? {
|
||||
blockAutoplay: existingModeration.original.blockAutoplay === true,
|
||||
blurThumbnail: existingModeration.original.blurThumbnail === true,
|
||||
}
|
||||
: {
|
||||
blockAutoplay: existingModeration.blockAutoplay === true,
|
||||
blurThumbnail: existingModeration.blurThumbnail === true,
|
||||
};
|
||||
|
||||
const decoratedModeration = {
|
||||
...existingModeration,
|
||||
reportType,
|
||||
trustedCount,
|
||||
trustedReporters: sanitizedReporters,
|
||||
reporterPubkeys,
|
||||
reporterDisplayNames,
|
||||
original: {
|
||||
blockAutoplay: originalState.blockAutoplay,
|
||||
blurThumbnail: originalState.blurThumbnail,
|
||||
},
|
||||
};
|
||||
|
||||
if (overrideActive) {
|
||||
decoratedModeration.blockAutoplay = false;
|
||||
decoratedModeration.blurThumbnail = false;
|
||||
decoratedModeration.viewerOverride = {
|
||||
showAnyway: true,
|
||||
updatedAt: overrideUpdatedAt,
|
||||
};
|
||||
} else {
|
||||
decoratedModeration.blockAutoplay = originalState.blockAutoplay;
|
||||
decoratedModeration.blurThumbnail = originalState.blurThumbnail;
|
||||
if (decoratedModeration.viewerOverride) {
|
||||
delete decoratedModeration.viewerOverride;
|
||||
}
|
||||
}
|
||||
|
||||
video.moderation = decoratedModeration;
|
||||
return video;
|
||||
}
|
||||
|
||||
handleModerationOverride({ video, card }) {
|
||||
if (!video || typeof video !== "object" || !video.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setModerationOverride(video.id, {
|
||||
showAnyway: true,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] Failed to persist moderation override:", error);
|
||||
}
|
||||
|
||||
const storedVideo =
|
||||
this.videosMap instanceof Map && video.id ? this.videosMap.get(video.id) : null;
|
||||
const target = storedVideo || video;
|
||||
|
||||
if (target) {
|
||||
this.decorateVideoModeration(target);
|
||||
}
|
||||
|
||||
if (this.currentVideo && this.currentVideo.id === video.id) {
|
||||
this.decorateVideoModeration(this.currentVideo);
|
||||
}
|
||||
|
||||
if (card && typeof card.refreshModerationUi === "function") {
|
||||
try {
|
||||
card.refreshModerationUi();
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] Failed to refresh moderation UI:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getVideoAddressPointer(video) {
|
||||
if (
|
||||
this.discussionCountService &&
|
||||
@@ -6200,6 +6454,8 @@ class Application {
|
||||
return;
|
||||
}
|
||||
|
||||
this.decorateVideoModeration(video);
|
||||
|
||||
let trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
|
||||
if (!trimmedUrl && fallbackUrl) {
|
||||
trimmedUrl = fallbackUrl;
|
||||
@@ -6255,6 +6511,8 @@ class Application {
|
||||
lastEditedAt: normalizedEditedAt,
|
||||
};
|
||||
|
||||
this.decorateVideoModeration(this.currentVideo);
|
||||
|
||||
if (Number.isFinite(knownPostedAt)) {
|
||||
this.cacheVideoRootCreatedAt(this.currentVideo, knownPostedAt);
|
||||
} else if (this.currentVideo.rootCreatedAt) {
|
||||
@@ -6631,6 +6889,8 @@ class Application {
|
||||
pointerKey: null,
|
||||
};
|
||||
|
||||
this.decorateVideoModeration(this.currentVideo);
|
||||
|
||||
this.syncModalMoreMenuData();
|
||||
|
||||
this.currentMagnetUri = sanitizedMagnet || null;
|
||||
|
||||
@@ -263,6 +263,7 @@ export function createModerationStage({
|
||||
|
||||
let summary = null;
|
||||
let trustedCount = 0;
|
||||
let trustedReporters = [];
|
||||
if (videoId) {
|
||||
try {
|
||||
if (typeof resolvedService.getTrustedReportSummary === "function") {
|
||||
@@ -271,10 +272,34 @@ export function createModerationStage({
|
||||
if (typeof resolvedService.trustedReportCount === "function") {
|
||||
trustedCount = resolvedService.trustedReportCount(videoId, normalizedReportType) || 0;
|
||||
}
|
||||
if (typeof resolvedService.getTrustedReporters === "function") {
|
||||
const reporterEntries = resolvedService.getTrustedReporters(
|
||||
videoId,
|
||||
normalizedReportType
|
||||
);
|
||||
if (Array.isArray(reporterEntries) && reporterEntries.length) {
|
||||
trustedReporters = reporterEntries
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const pubkey = typeof entry.pubkey === "string" ? entry.pubkey.trim() : "";
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
const latest = Number.isFinite(entry.latest)
|
||||
? Math.floor(entry.latest)
|
||||
: 0;
|
||||
return { pubkey, latest };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] Failed to fetch moderation summary`, error);
|
||||
summary = null;
|
||||
trustedCount = 0;
|
||||
trustedReporters = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +318,11 @@ export function createModerationStage({
|
||||
metadataModeration.blockAutoplay = blockAutoplay;
|
||||
metadataModeration.blurThumbnail = blurThumbnail;
|
||||
metadataModeration.summary = summary;
|
||||
metadataModeration.trustedCount = trustedCount;
|
||||
metadataModeration.reportType = normalizedReportType;
|
||||
metadataModeration.trustedReporters = Array.isArray(trustedReporters)
|
||||
? trustedReporters.slice()
|
||||
: [];
|
||||
item.metadata.moderation = metadataModeration;
|
||||
|
||||
if (!video.moderation || typeof video.moderation !== "object") {
|
||||
@@ -301,6 +331,13 @@ export function createModerationStage({
|
||||
|
||||
video.moderation.blockAutoplay = blockAutoplay;
|
||||
video.moderation.blurThumbnail = blurThumbnail;
|
||||
video.moderation.trustedCount = trustedCount;
|
||||
video.moderation.reportType = normalizedReportType;
|
||||
if (Array.isArray(trustedReporters) && trustedReporters.length) {
|
||||
video.moderation.trustedReporters = trustedReporters.slice();
|
||||
} else if (video.moderation.trustedReporters) {
|
||||
delete video.moderation.trustedReporters;
|
||||
}
|
||||
if (summary) {
|
||||
video.moderation.summary = summary;
|
||||
} else if (video.moderation.summary) {
|
||||
|
||||
@@ -664,6 +664,58 @@ export class ModerationService {
|
||||
return cloneSummary(summary);
|
||||
}
|
||||
|
||||
getTrustedReporters(eventId, type) {
|
||||
const normalizedEventId = normalizeEventId(eventId);
|
||||
if (!normalizedEventId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventReports = this.reportEvents.get(normalizedEventId);
|
||||
if (!eventReports || !eventReports.size) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedType = normalizeReportType(type);
|
||||
const results = [];
|
||||
|
||||
for (const [reporter, typeMap] of eventReports.entries()) {
|
||||
if (!this.trustedContacts.has(reporter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedType) {
|
||||
const detail = typeMap.get(normalizedType);
|
||||
if (!detail) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
pubkey: reporter,
|
||||
latest: ensureNumber(detail?.created_at),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let latest = 0;
|
||||
for (const entry of typeMap.values()) {
|
||||
const candidate = ensureNumber(entry?.created_at);
|
||||
if (candidate > latest) {
|
||||
latest = candidate;
|
||||
}
|
||||
}
|
||||
results.push({ pubkey: reporter, latest });
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
results.sort((a, b) => ensureNumber(b.latest) - ensureNumber(a.latest));
|
||||
return results.map((entry) => ({
|
||||
pubkey: entry.pubkey,
|
||||
latest: ensureNumber(entry.latest),
|
||||
}));
|
||||
}
|
||||
|
||||
trustedReportCount(eventId, type) {
|
||||
const normalizedEventId = normalizeEventId(eventId);
|
||||
if (!normalizedEventId) {
|
||||
|
||||
@@ -20,6 +20,9 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
|
||||
const MODERATION_OVERRIDE_STORAGE_VERSION = 1;
|
||||
|
||||
function sanitizeProfileString(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
@@ -34,6 +37,7 @@ let activeProfilePubkey = null;
|
||||
const profileCache = new Map();
|
||||
const urlHealthCache = new Map();
|
||||
const urlHealthInFlight = new Map();
|
||||
const moderationOverrides = new Map();
|
||||
|
||||
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
||||
if (previousProfiles === nextProfiles) {
|
||||
@@ -113,6 +117,19 @@ function normalizeHexPubkey(pubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEventId(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return HEX64_REGEX.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
export function getSavedProfiles() {
|
||||
return savedProfiles;
|
||||
}
|
||||
@@ -637,6 +654,178 @@ export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function getModerationOverridesMap() {
|
||||
return moderationOverrides;
|
||||
}
|
||||
|
||||
export function getModerationOverride(eventId) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = moderationOverrides.get(normalized);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
export function loadModerationOverridesFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.version !== MODERATION_OVERRIDE_STORAGE_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = payload.entries && typeof payload.entries === "object"
|
||||
? payload.entries
|
||||
: {};
|
||||
|
||||
moderationOverrides.clear();
|
||||
|
||||
for (const [eventId, entry] of Object.entries(entries)) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (!entry || entry.showAnyway !== true) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = Number.isFinite(entry.updatedAt)
|
||||
? Math.floor(entry.updatedAt)
|
||||
: Date.now();
|
||||
moderationOverrides.set(normalized, {
|
||||
showAnyway: true,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
moderationOverrides.clear();
|
||||
userLogger.warn(
|
||||
"[cache.loadModerationOverridesFromStorage] Failed to parse payload:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistModerationOverridesToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = {};
|
||||
for (const [eventId, entry] of moderationOverrides.entries()) {
|
||||
if (!entry || entry.showAnyway !== true) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = Number.isFinite(entry.updatedAt)
|
||||
? Math.floor(entry.updatedAt)
|
||||
: Date.now();
|
||||
entries[eventId] = {
|
||||
showAnyway: true,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(MODERATION_OVERRIDE_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.persistModerationOverridesToStorage] Failed to clear overrides:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: MODERATION_OVERRIDE_STORAGE_VERSION,
|
||||
savedAt: Date.now(),
|
||||
entries,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
MODERATION_OVERRIDE_STORAGE_KEY,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.persistModerationOverridesToStorage] Failed to persist overrides:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function setModerationOverride(
|
||||
eventId,
|
||||
override = {},
|
||||
{ persist = true } = {},
|
||||
) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showAnyway = override?.showAnyway === true;
|
||||
if (!showAnyway) {
|
||||
const removed = moderationOverrides.delete(normalized);
|
||||
if (removed && persist) {
|
||||
persistModerationOverridesToStorage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedAt = Number.isFinite(override?.updatedAt)
|
||||
? Math.floor(override.updatedAt)
|
||||
: Date.now();
|
||||
|
||||
const existing = moderationOverrides.get(normalized);
|
||||
const nextEntry = { showAnyway: true, updatedAt };
|
||||
const changed =
|
||||
!existing ||
|
||||
existing.showAnyway !== nextEntry.showAnyway ||
|
||||
existing.updatedAt !== nextEntry.updatedAt;
|
||||
|
||||
moderationOverrides.set(normalized, nextEntry);
|
||||
|
||||
if (persist && changed) {
|
||||
persistModerationOverridesToStorage();
|
||||
}
|
||||
|
||||
return { ...nextEntry };
|
||||
}
|
||||
|
||||
export function clearModerationOverride(eventId, { persist = true } = {}) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const removed = moderationOverrides.delete(normalized);
|
||||
if (removed && persist) {
|
||||
persistModerationOverridesToStorage();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function buildUrlProbeKey(url, options = {}) {
|
||||
const trimmed = typeof url === "string" ? url : "";
|
||||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||||
|
||||
+434
-25
@@ -105,9 +105,17 @@ export class VideoCard {
|
||||
onRequestMoreMenu: null,
|
||||
onCloseMoreMenu: null,
|
||||
onRequestSettingsMenu: null,
|
||||
onCloseSettingsMenu: null
|
||||
onCloseSettingsMenu: null,
|
||||
onModerationOverride: null,
|
||||
};
|
||||
|
||||
this.moderationBadgeEl = null;
|
||||
this.moderationBadgeTextEl = null;
|
||||
this.moderationActionButton = null;
|
||||
this.moderationBadgeId = "";
|
||||
this.badgesContainerEl = null;
|
||||
this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event);
|
||||
|
||||
this.root = null;
|
||||
this.anchorEl = null;
|
||||
this.titleEl = null;
|
||||
@@ -178,6 +186,10 @@ export class VideoCard {
|
||||
this.callbacks.onDelete = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
set onModerationOverride(fn) {
|
||||
this.callbacks.onModerationOverride = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
set onMoreAction(fn) {
|
||||
this.callbacks.onMoreAction = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
@@ -356,6 +368,7 @@ export class VideoCard {
|
||||
const content = this.createElement("div", {
|
||||
classNames: ["p-md", "bv-stack", "bv-stack--tight"]
|
||||
});
|
||||
this.contentEl = content;
|
||||
|
||||
const title = this.createElement("h3", {
|
||||
classNames: [
|
||||
@@ -863,6 +876,11 @@ export class VideoCard {
|
||||
pieces.push(badge);
|
||||
}
|
||||
|
||||
const moderationBadge = this.buildModerationBadge();
|
||||
if (moderationBadge) {
|
||||
pieces.push(moderationBadge);
|
||||
}
|
||||
|
||||
if (!pieces.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -871,9 +889,407 @@ export class VideoCard {
|
||||
classNames: ["flex", "flex-wrap", "items-center", "gap-sm"]
|
||||
});
|
||||
pieces.forEach((el) => container.appendChild(el));
|
||||
this.badgesContainerEl = container;
|
||||
this.updateModerationAria();
|
||||
return container;
|
||||
}
|
||||
|
||||
getModerationBadgeId() {
|
||||
if (this.moderationBadgeId) {
|
||||
return this.moderationBadgeId;
|
||||
}
|
||||
|
||||
const baseId =
|
||||
typeof this.video?.id === "string" && this.video.id
|
||||
? this.video.id
|
||||
: `index-${this.index}`;
|
||||
const sanitized = baseId.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const id = `video-card-${sanitized}-moderation`;
|
||||
this.moderationBadgeId = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
getModerationContext() {
|
||||
const moderation =
|
||||
this.video?.moderation && typeof this.video.moderation === "object"
|
||||
? this.video.moderation
|
||||
: null;
|
||||
|
||||
const summary =
|
||||
moderation?.summary && typeof moderation.summary === "object"
|
||||
? moderation.summary
|
||||
: null;
|
||||
|
||||
let reportType = "";
|
||||
if (typeof moderation?.reportType === "string" && moderation.reportType.trim()) {
|
||||
reportType = moderation.reportType.trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (!reportType && summary && summary.types && typeof summary.types === "object") {
|
||||
for (const [type, stats] of Object.entries(summary.types)) {
|
||||
if (stats && Number.isFinite(stats.trusted) && Math.floor(stats.trusted) > 0) {
|
||||
reportType = String(type).toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trustedCount = Number.isFinite(moderation?.trustedCount)
|
||||
? Math.max(0, Math.floor(moderation.trustedCount))
|
||||
: 0;
|
||||
|
||||
if (!trustedCount && summary && summary.types && typeof summary.types === "object") {
|
||||
for (const stats of Object.values(summary.types)) {
|
||||
if (stats && Number.isFinite(stats.trusted)) {
|
||||
trustedCount = Math.max(trustedCount, Math.floor(stats.trusted));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reporterDisplayNames = Array.isArray(moderation?.reporterDisplayNames)
|
||||
? moderation.reporterDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const original =
|
||||
moderation?.original && typeof moderation.original === "object"
|
||||
? moderation.original
|
||||
: {};
|
||||
|
||||
const context = {
|
||||
reportType,
|
||||
friendlyType: reportType ? reportType.replace(/[_-]+/g, " ").trim() : "",
|
||||
trustedCount,
|
||||
reporterDisplayNames,
|
||||
originalBlur: original.blurThumbnail === true,
|
||||
originalBlockAutoplay: original.blockAutoplay === true,
|
||||
activeBlur: moderation?.blurThumbnail === true,
|
||||
activeBlockAutoplay: moderation?.blockAutoplay === true,
|
||||
overrideActive: moderation?.viewerOverride?.showAnyway === true,
|
||||
};
|
||||
|
||||
context.shouldShow =
|
||||
context.originalBlur ||
|
||||
context.originalBlockAutoplay ||
|
||||
context.trustedCount > 0 ||
|
||||
context.overrideActive;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
buildModerationReasonText(context) {
|
||||
if (!context) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const typeLabel = context.friendlyType || "this video";
|
||||
const count = Math.max(0, Number(context.trustedCount) || 0);
|
||||
|
||||
if (count <= 0) {
|
||||
return context.friendlyType ? `reports of ${typeLabel}` : "reports";
|
||||
}
|
||||
|
||||
const friendLabel = count === 1 ? "friend" : "friends";
|
||||
return `${count} ${friendLabel} reported ${typeLabel}`;
|
||||
}
|
||||
|
||||
buildModerationBadgeText(context) {
|
||||
if (!context) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reason = this.buildModerationReasonText(context);
|
||||
if (context.overrideActive) {
|
||||
if (reason) {
|
||||
return `Showing despite ${reason}`;
|
||||
}
|
||||
return "Showing despite reports";
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (context.originalBlur) {
|
||||
parts.push("Blurred");
|
||||
}
|
||||
if (context.originalBlockAutoplay) {
|
||||
parts.push("Autoplay blocked");
|
||||
}
|
||||
if (reason) {
|
||||
parts.push(reason);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
createModerationOverrideButton() {
|
||||
const button = this.createElement("button", {
|
||||
classNames: [
|
||||
"inline-flex",
|
||||
"items-center",
|
||||
"rounded-full",
|
||||
"border",
|
||||
"border-status-warning-border",
|
||||
"px-3",
|
||||
"py-1",
|
||||
"text-2xs",
|
||||
"font-semibold",
|
||||
"uppercase",
|
||||
"tracking-extra-wide",
|
||||
"text-status-warning-on",
|
||||
"bg-transparent",
|
||||
"hover:bg-status-warning-surface",
|
||||
"transition",
|
||||
"duration-150",
|
||||
"focus-visible:outline",
|
||||
"focus-visible:outline-2",
|
||||
"focus-visible:outline-offset-2",
|
||||
"focus-visible:outline-status-warning-border",
|
||||
],
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-moderation-action": "override",
|
||||
"aria-pressed": "false",
|
||||
"aria-describedby": this.getModerationBadgeId(),
|
||||
},
|
||||
textContent: "Show anyway",
|
||||
});
|
||||
button.addEventListener("click", this.boundShowAnywayHandler);
|
||||
return button;
|
||||
}
|
||||
|
||||
handleShowAnywayClick(event) {
|
||||
if (event) {
|
||||
if (typeof event.preventDefault === "function") {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (typeof event.stopPropagation === "function") {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
const button = this.moderationActionButton;
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-busy", "true");
|
||||
}
|
||||
|
||||
if (!this.callbacks.onModerationOverride) {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-busy");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = this.callbacks.onModerationOverride({
|
||||
event,
|
||||
video: this.video,
|
||||
card: this,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn("[VideoCard] onModerationOverride callback threw", error);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-busy");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(result)
|
||||
.then((handled) => {
|
||||
if (handled === false) {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-busy");
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.refreshModerationUi();
|
||||
})
|
||||
.catch((error) => {
|
||||
userLogger.warn("[VideoCard] Moderation override failed", error);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-busy");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildModerationBadge() {
|
||||
const context = this.getModerationContext();
|
||||
if (!context.shouldShow) {
|
||||
this.moderationBadgeEl = null;
|
||||
this.moderationBadgeTextEl = null;
|
||||
if (this.moderationActionButton) {
|
||||
this.moderationActionButton.removeEventListener(
|
||||
"click",
|
||||
this.boundShowAnywayHandler,
|
||||
);
|
||||
}
|
||||
this.moderationActionButton = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const badge = this.createElement("div", {
|
||||
classNames: ["badge", "flex", "flex-wrap", "items-center", "gap-sm"],
|
||||
});
|
||||
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
|
||||
badge.dataset.moderationBadge = "true";
|
||||
badge.dataset.moderationState = context.overrideActive ? "override" : "blocked";
|
||||
|
||||
const badgeId = this.getModerationBadgeId();
|
||||
badge.id = badgeId;
|
||||
badge.setAttribute("role", "status");
|
||||
badge.setAttribute("aria-live", "polite");
|
||||
badge.setAttribute("aria-atomic", "true");
|
||||
|
||||
const text = this.createElement("span", {
|
||||
classNames: ["whitespace-nowrap"],
|
||||
textContent: this.buildModerationBadgeText(context),
|
||||
});
|
||||
badge.appendChild(text);
|
||||
|
||||
const names = context.reporterDisplayNames;
|
||||
if (names.length) {
|
||||
const joined = names.join(", ");
|
||||
badge.title = `Reported by ${joined}`;
|
||||
badge.setAttribute(
|
||||
"aria-label",
|
||||
`${text.textContent}. Reported by ${joined}.`,
|
||||
);
|
||||
} else {
|
||||
badge.removeAttribute("title");
|
||||
badge.setAttribute("aria-label", `${text.textContent}.`);
|
||||
}
|
||||
|
||||
this.moderationBadgeEl = badge;
|
||||
this.moderationBadgeTextEl = text;
|
||||
|
||||
if (!context.overrideActive) {
|
||||
const button = this.createModerationOverrideButton();
|
||||
badge.appendChild(button);
|
||||
this.moderationActionButton = button;
|
||||
} else {
|
||||
this.moderationActionButton = null;
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
||||
updateModerationBadge() {
|
||||
const context = this.getModerationContext();
|
||||
const badge = this.moderationBadgeEl;
|
||||
|
||||
if (!context.shouldShow) {
|
||||
if (badge && badge.parentElement) {
|
||||
badge.parentElement.removeChild(badge);
|
||||
}
|
||||
if (this.moderationActionButton) {
|
||||
this.moderationActionButton.removeEventListener(
|
||||
"click",
|
||||
this.boundShowAnywayHandler,
|
||||
);
|
||||
}
|
||||
this.moderationBadgeEl = null;
|
||||
this.moderationBadgeTextEl = null;
|
||||
this.moderationActionButton = null;
|
||||
this.updateModerationAria();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!badge) {
|
||||
const nextBadge = this.buildModerationBadge();
|
||||
if (nextBadge) {
|
||||
if (!this.badgesContainerEl) {
|
||||
this.badgesContainerEl = this.createElement("div", {
|
||||
classNames: ["flex", "flex-wrap", "items-center", "gap-sm"],
|
||||
});
|
||||
if (this.contentEl) {
|
||||
if (
|
||||
this.discussionCountEl &&
|
||||
this.discussionCountEl.parentElement === this.contentEl
|
||||
) {
|
||||
this.contentEl.insertBefore(
|
||||
this.badgesContainerEl,
|
||||
this.discussionCountEl,
|
||||
);
|
||||
} else {
|
||||
this.contentEl.appendChild(this.badgesContainerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.badgesContainerEl.appendChild(nextBadge);
|
||||
}
|
||||
this.updateModerationAria();
|
||||
return;
|
||||
}
|
||||
|
||||
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
|
||||
badge.dataset.moderationState = context.overrideActive ? "override" : "blocked";
|
||||
|
||||
const textContent = this.buildModerationBadgeText(context);
|
||||
if (this.moderationBadgeTextEl) {
|
||||
this.moderationBadgeTextEl.textContent = textContent;
|
||||
}
|
||||
|
||||
const names = context.reporterDisplayNames;
|
||||
if (names.length) {
|
||||
const joined = names.join(", ");
|
||||
badge.title = `Reported by ${joined}`;
|
||||
badge.setAttribute("aria-label", `${textContent}. Reported by ${joined}.`);
|
||||
} else {
|
||||
badge.removeAttribute("title");
|
||||
badge.setAttribute("aria-label", `${textContent}.`);
|
||||
}
|
||||
|
||||
if (context.overrideActive) {
|
||||
if (this.moderationActionButton) {
|
||||
this.moderationActionButton.removeEventListener(
|
||||
"click",
|
||||
this.boundShowAnywayHandler,
|
||||
);
|
||||
this.moderationActionButton.remove();
|
||||
}
|
||||
this.moderationActionButton = null;
|
||||
} else if (!this.moderationActionButton) {
|
||||
const button = this.createModerationOverrideButton();
|
||||
badge.appendChild(button);
|
||||
this.moderationActionButton = button;
|
||||
} else {
|
||||
this.moderationActionButton.disabled = false;
|
||||
this.moderationActionButton.removeAttribute("aria-busy");
|
||||
}
|
||||
}
|
||||
|
||||
updateModerationAria() {
|
||||
const badgeId = this.moderationBadgeEl ? this.getModerationBadgeId() : "";
|
||||
const elements = [this.anchorEl, this.titleEl].filter(Boolean);
|
||||
elements.forEach((el) => {
|
||||
if (!el || typeof el.getAttribute !== "function") {
|
||||
return;
|
||||
}
|
||||
const attr = el.getAttribute("aria-describedby") || "";
|
||||
const tokens = attr.split(/\s+/).filter(Boolean);
|
||||
const filtered = tokens.filter((token) => token !== this.moderationBadgeId);
|
||||
if (badgeId) {
|
||||
filtered.push(badgeId);
|
||||
}
|
||||
if (filtered.length) {
|
||||
el.setAttribute("aria-describedby", Array.from(new Set(filtered)).join(" "));
|
||||
} else {
|
||||
el.removeAttribute("aria-describedby");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshModerationUi() {
|
||||
this.applyModerationDatasets();
|
||||
this.updateModerationBadge();
|
||||
this.updateModerationAria();
|
||||
}
|
||||
|
||||
getCachedUrlHealthEntry() {
|
||||
if (!this.video?.id || !(this.state.urlHealthByVideoId instanceof Map)) {
|
||||
return null;
|
||||
@@ -1325,43 +1741,36 @@ export class VideoCard {
|
||||
return;
|
||||
}
|
||||
|
||||
const moderation =
|
||||
this.video?.moderation && typeof this.video.moderation === "object"
|
||||
? this.video.moderation
|
||||
: null;
|
||||
const moderationContext = this.getModerationContext();
|
||||
|
||||
const blockAutoplay = moderation?.blockAutoplay === true;
|
||||
const blurThumbnail = moderation?.blurThumbnail === true;
|
||||
const summary =
|
||||
moderation && typeof moderation.summary === "object"
|
||||
? moderation.summary
|
||||
: null;
|
||||
|
||||
if (blockAutoplay) {
|
||||
if (moderationContext.originalBlockAutoplay && !moderationContext.overrideActive) {
|
||||
this.root.dataset.autoplayPolicy = "blocked";
|
||||
} else if (this.root.dataset.autoplayPolicy === "blocked") {
|
||||
} else if (this.root.dataset.autoplayPolicy) {
|
||||
delete this.root.dataset.autoplayPolicy;
|
||||
}
|
||||
|
||||
if (moderationContext.overrideActive) {
|
||||
this.root.dataset.moderationOverride = "show-anyway";
|
||||
} else if (this.root.dataset.moderationOverride) {
|
||||
delete this.root.dataset.moderationOverride;
|
||||
}
|
||||
|
||||
if (this.thumbnailEl && !this.shouldMaskNsfwForOwner) {
|
||||
if (blurThumbnail) {
|
||||
if (moderationContext.activeBlur) {
|
||||
this.thumbnailEl.dataset.thumbnailState = "blurred";
|
||||
} else if (this.thumbnailEl.dataset.thumbnailState === "blurred") {
|
||||
delete this.thumbnailEl.dataset.thumbnailState;
|
||||
}
|
||||
}
|
||||
|
||||
let nudityTrustedCount = 0;
|
||||
if (summary && summary.types && typeof summary.types === "object") {
|
||||
const nudityEntry = summary.types.nudity || summary.types["nudity"];
|
||||
if (nudityEntry && Number.isFinite(nudityEntry.trusted)) {
|
||||
nudityTrustedCount = nudityEntry.trusted;
|
||||
const reportCount = Math.max(0, Number(moderationContext.trustedCount) || 0);
|
||||
if (reportCount > 0) {
|
||||
this.root.dataset.moderationReportCount = String(reportCount);
|
||||
if (moderationContext.reportType) {
|
||||
this.root.dataset.moderationReportType = moderationContext.reportType;
|
||||
} else if (this.root.dataset.moderationReportType) {
|
||||
delete this.root.dataset.moderationReportType;
|
||||
}
|
||||
}
|
||||
|
||||
if (nudityTrustedCount > 0) {
|
||||
this.root.dataset.moderationReportType = "nudity";
|
||||
this.root.dataset.moderationReportCount = String(nudityTrustedCount);
|
||||
} else {
|
||||
if (this.root.dataset.moderationReportType) {
|
||||
delete this.root.dataset.moderationReportType;
|
||||
|
||||
@@ -181,6 +181,7 @@ export class VideoListView {
|
||||
revert: null,
|
||||
delete: null,
|
||||
blacklist: null,
|
||||
moderationOverride: null,
|
||||
};
|
||||
|
||||
this.allowNsfw = allowNsfw !== false;
|
||||
@@ -277,6 +278,10 @@ export class VideoListView {
|
||||
this.handlers.blacklist = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setModerationOverrideHandler(handler) {
|
||||
this.handlers.moderationOverride = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
render(videos, metadata = null) {
|
||||
if (!this.container) {
|
||||
return [];
|
||||
@@ -365,6 +370,15 @@ export class VideoListView {
|
||||
|
||||
authorSet.add(video.pubkey);
|
||||
|
||||
const reporterPubkeys = Array.isArray(video?.moderation?.reporterPubkeys)
|
||||
? video.moderation.reporterPubkeys
|
||||
: [];
|
||||
reporterPubkeys.forEach((pubkey) => {
|
||||
if (typeof pubkey === "string" && pubkey.trim()) {
|
||||
authorSet.add(pubkey.trim());
|
||||
}
|
||||
});
|
||||
|
||||
const shareUrl = this.buildShareUrl(video, shareBase);
|
||||
const canEdit = this.utils.canEditVideo(video);
|
||||
const canDelete = this.utils.canDeleteVideo(video);
|
||||
@@ -455,6 +469,20 @@ export class VideoListView {
|
||||
this.emitSelected(detail);
|
||||
};
|
||||
|
||||
videoCard.onModerationOverride = ({ event: overrideEvent }) => {
|
||||
if (!this.handlers.moderationOverride) {
|
||||
return false;
|
||||
}
|
||||
const trigger =
|
||||
overrideEvent?.currentTarget || overrideEvent?.target || null;
|
||||
return this.handlers.moderationOverride({
|
||||
event: overrideEvent,
|
||||
video,
|
||||
card: videoCard,
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
|
||||
videoCard.onEdit = ({ event: editEvent, video: editVideo, index: editIndex }) => {
|
||||
if (this.handlers.edit) {
|
||||
const trigger = editEvent?.currentTarget || editEvent?.target || null;
|
||||
|
||||
Reference in new issue
Block a user