mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
Merge pull request #637 from PR0M3TH3AN/codex/implement-f1-mute-list-management
Add trusted mute aggregation and UI signals for F1 mutes
This commit is contained in:
16 files changed
+1546
-40
No files matched your search
@@ -119,6 +119,25 @@
|
||||
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "trusted-mute",
|
||||
heading: "Trusted mute (F1 mute list)",
|
||||
description:
|
||||
"Card should explain that a trusted contact muted this author without enabling overrides.",
|
||||
video: {
|
||||
id: "4".repeat(64),
|
||||
title: "Fixture: Trusted Mute",
|
||||
pubkey: "aa".repeat(32),
|
||||
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
|
||||
},
|
||||
moderation: {
|
||||
blockAutoplay: false,
|
||||
blurThumbnail: false,
|
||||
trustedMuted: true,
|
||||
trustedMuteCount: 1,
|
||||
},
|
||||
muters: [{ name: "Jess F1", pubkey: "b".repeat(64) }],
|
||||
},
|
||||
];
|
||||
|
||||
window.__MODERATION_FIXTURE_STORAGE_KEY__ = STORAGE_KEY;
|
||||
@@ -199,6 +218,25 @@
|
||||
base.summary = summary;
|
||||
}
|
||||
|
||||
if (fixture.moderation.trustedMuted) {
|
||||
base.trustedMuted = true;
|
||||
const muterPubkeys = Array.isArray(fixture.muters)
|
||||
? fixture.muters
|
||||
.map((muter) => (typeof muter?.pubkey === "string" ? muter.pubkey : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
base.trustedMuters = muterPubkeys;
|
||||
const muteCount = Number.isFinite(fixture.moderation.trustedMuteCount)
|
||||
? Math.max(0, Math.floor(fixture.moderation.trustedMuteCount))
|
||||
: muterPubkeys.length;
|
||||
base.trustedMuteCount = muteCount;
|
||||
base.trustedMuterDisplayNames = Array.isArray(fixture.muters)
|
||||
? fixture.muters
|
||||
.map((muter) => (typeof muter?.name === "string" ? muter.name : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
if (overrideEntry?.showAnyway) {
|
||||
base.viewerOverride = {
|
||||
showAnyway: true,
|
||||
|
||||
@@ -4550,6 +4550,18 @@ class Application {
|
||||
|
||||
const reporterPubkeys = sanitizedReporters.map((entry) => entry.pubkey);
|
||||
|
||||
const rawTrustedMuters = Array.isArray(existingModeration.trustedMuters)
|
||||
? existingModeration.trustedMuters
|
||||
.map((value) => (typeof value === "string" ? value.trim().toLowerCase() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const trustedMuteCount = Number.isFinite(existingModeration.trustedMuteCount)
|
||||
? Math.max(0, Math.floor(existingModeration.trustedMuteCount))
|
||||
: rawTrustedMuters.length;
|
||||
|
||||
const trustedMuted = existingModeration.trustedMuted === true || trustedMuteCount > 0;
|
||||
|
||||
const reporterDisplayNames = [];
|
||||
const seenNames = new Set();
|
||||
for (const reporterPubkey of reporterPubkeys) {
|
||||
@@ -4569,6 +4581,27 @@ class Application {
|
||||
reporterDisplayNames.push(normalizedName);
|
||||
}
|
||||
|
||||
const trustedMuterDisplayNames = [];
|
||||
if (trustedMuted) {
|
||||
const seenMuteNames = new Set();
|
||||
for (const muterPubkey of rawTrustedMuters) {
|
||||
const name = this.getReporterDisplayName(muterPubkey);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizedName.toLowerCase();
|
||||
if (seenMuteNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenMuteNames.add(key);
|
||||
trustedMuterDisplayNames.push(normalizedName);
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCount = Number.isFinite(existingModeration.trustedCount)
|
||||
? Math.max(0, Math.floor(existingModeration.trustedCount))
|
||||
: this.deriveModerationTrustedCount(summary, reportType);
|
||||
@@ -4597,6 +4630,10 @@ class Application {
|
||||
trustedReporters: sanitizedReporters,
|
||||
reporterPubkeys,
|
||||
reporterDisplayNames,
|
||||
trustedMuted,
|
||||
trustedMuters: rawTrustedMuters,
|
||||
trustedMuteCount,
|
||||
trustedMuterDisplayNames,
|
||||
original: {
|
||||
blockAutoplay: originalState.blockAutoplay,
|
||||
blurThumbnail: originalState.blurThumbnail,
|
||||
|
||||
+42
-1
@@ -5,7 +5,7 @@ import {
|
||||
DEFAULT_RELAY_URLS,
|
||||
convertEventToVideo as sharedConvertEventToVideo
|
||||
} from "./nostr.js";
|
||||
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
||||
import { subscriptions } from "./subscriptions.js";
|
||||
import { attachHealthBadges } from "./gridHealth.js";
|
||||
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
@@ -16,6 +16,7 @@ import { VideoCard } from "./ui/components/VideoCard.js";
|
||||
import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "./config.js";
|
||||
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
|
||||
import moderationService from "./services/moderationService.js";
|
||||
import {
|
||||
calculateZapShares,
|
||||
describeShareType,
|
||||
@@ -1663,12 +1664,31 @@ async function updateChannelMenuState() {
|
||||
userLogger.warn("Failed to refresh moderation lists for channel menu:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await moderationService.ensureViewerMuteListLoaded();
|
||||
} catch (error) {
|
||||
userLogger.warn("Failed to refresh viewer mute list for channel menu:", error);
|
||||
}
|
||||
|
||||
const canBlacklist =
|
||||
typeof app?.canCurrentUserManageBlacklist === "function"
|
||||
? app.canCurrentUserManageBlacklist()
|
||||
: false;
|
||||
|
||||
const buttons = menu.querySelectorAll("button[data-action]");
|
||||
const toggleButtonVisibility = (button, shouldShow) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
if (shouldShow) {
|
||||
button.removeAttribute("hidden");
|
||||
button.setAttribute("aria-hidden", "false");
|
||||
} else {
|
||||
button.setAttribute("hidden", "");
|
||||
button.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
};
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (!button || button.nodeType !== 1) {
|
||||
return;
|
||||
@@ -1701,6 +1721,27 @@ async function updateChannelMenuState() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "mute-author" || action === "unmute-author") {
|
||||
if (currentChannelHex) {
|
||||
button.dataset.author = currentChannelHex;
|
||||
} else {
|
||||
delete button.dataset.author;
|
||||
}
|
||||
|
||||
let isMuted = false;
|
||||
if (currentChannelHex) {
|
||||
try {
|
||||
isMuted = moderationService.isAuthorMutedByViewer(currentChannelHex) === true;
|
||||
} catch (error) {
|
||||
userLogger.warn("Failed to resolve viewer mute state for channel menu:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShow = action === "mute-author" ? !isMuted : isMuted;
|
||||
toggleButtonVisibility(button, shouldShow && !!currentChannelHex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "blacklist-author") {
|
||||
if (canBlacklist && currentChannelHex) {
|
||||
button.dataset.author = currentChannelHex;
|
||||
|
||||
@@ -10,6 +10,17 @@ export function createChronologicalSorter({ direction = "desc" } = {}) {
|
||||
|
||||
const copy = [...items];
|
||||
copy.sort((a, b) => {
|
||||
const aMuted =
|
||||
a?.metadata?.moderation?.trustedMuted === true ||
|
||||
a?.video?.moderation?.trustedMuted === true;
|
||||
const bMuted =
|
||||
b?.metadata?.moderation?.trustedMuted === true ||
|
||||
b?.video?.moderation?.trustedMuted === true;
|
||||
|
||||
if (aMuted !== bMuted) {
|
||||
return aMuted ? 1 : -1;
|
||||
}
|
||||
|
||||
const aTs = Number(a?.video?.created_at);
|
||||
const bTs = Number(b?.video?.created_at);
|
||||
const normalizedATs = Number.isFinite(aTs) ? aTs : Number.NEGATIVE_INFINITY;
|
||||
|
||||
@@ -384,6 +384,9 @@ export function createModerationStage({
|
||||
let summary = null;
|
||||
let trustedCount = 0;
|
||||
let trustedReporters = [];
|
||||
let trustedMuted = false;
|
||||
let trustedMuters = [];
|
||||
let trustedMuteCount = 0;
|
||||
if (videoId) {
|
||||
try {
|
||||
if (typeof resolvedService.getTrustedReportSummary === "function") {
|
||||
@@ -423,6 +426,48 @@ export function createModerationStage({
|
||||
}
|
||||
}
|
||||
|
||||
if (authorHex) {
|
||||
try {
|
||||
if (typeof resolvedService.isAuthorMutedByTrusted === "function") {
|
||||
trustedMuted = resolvedService.isAuthorMutedByTrusted(authorHex) === true;
|
||||
}
|
||||
if (trustedMuted && typeof resolvedService.getTrustedMutersForAuthor === "function") {
|
||||
const muters = resolvedService.getTrustedMutersForAuthor(authorHex);
|
||||
if (Array.isArray(muters)) {
|
||||
const seen = new Set();
|
||||
trustedMuters = muters
|
||||
.map((value) => (typeof value === "string" ? value.trim().toLowerCase() : ""))
|
||||
.filter((value) => {
|
||||
if (!value || seen.has(value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] Failed to resolve trusted mute info`, error);
|
||||
trustedMuted = false;
|
||||
trustedMuters = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (trustedMuted) {
|
||||
trustedMuteCount = Number.isFinite(video?.moderation?.trustedMuteCount)
|
||||
? Math.max(0, Math.floor(video.moderation.trustedMuteCount))
|
||||
: trustedMuters.length;
|
||||
if (!trustedMuteCount) {
|
||||
trustedMuteCount = trustedMuters.length;
|
||||
}
|
||||
if (trustedMuteCount <= 0) {
|
||||
trustedMuteCount = Math.max(1, trustedMuters.length || 1);
|
||||
}
|
||||
} else {
|
||||
trustedMuteCount = 0;
|
||||
trustedMuters = [];
|
||||
}
|
||||
|
||||
let blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
|
||||
let blurThumbnail = trustedCount >= normalizedBlurThreshold;
|
||||
const adminWhitelistBypass = isDiscoveryFeed && adminStatus?.whitelisted === true;
|
||||
@@ -450,6 +495,9 @@ export function createModerationStage({
|
||||
: [];
|
||||
metadataModeration.adminWhitelist = adminStatus?.whitelisted === true;
|
||||
metadataModeration.adminWhitelistBypass = adminWhitelistBypass;
|
||||
metadataModeration.trustedMuted = trustedMuted;
|
||||
metadataModeration.trustedMuters = trustedMuters.slice();
|
||||
metadataModeration.trustedMuteCount = trustedMuteCount;
|
||||
item.metadata.moderation = metadataModeration;
|
||||
|
||||
if (!video.moderation || typeof video.moderation !== "object") {
|
||||
@@ -462,6 +510,18 @@ export function createModerationStage({
|
||||
video.moderation.reportType = normalizedReportType;
|
||||
video.moderation.adminWhitelist = adminStatus?.whitelisted === true;
|
||||
video.moderation.adminWhitelistBypass = adminWhitelistBypass;
|
||||
video.moderation.trustedMuted = trustedMuted;
|
||||
if (trustedMuted) {
|
||||
video.moderation.trustedMuters = trustedMuters.slice();
|
||||
video.moderation.trustedMuteCount = trustedMuteCount;
|
||||
} else {
|
||||
if (video.moderation.trustedMuters) {
|
||||
delete video.moderation.trustedMuters;
|
||||
}
|
||||
if (video.moderation.trustedMuteCount) {
|
||||
delete video.moderation.trustedMuteCount;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(trustedReporters) && trustedReporters.length) {
|
||||
video.moderation.trustedReporters = trustedReporters.slice();
|
||||
} else if (video.moderation.trustedReporters) {
|
||||
@@ -484,6 +544,17 @@ export function createModerationStage({
|
||||
});
|
||||
}
|
||||
|
||||
if (trustedMuted) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "moderation",
|
||||
reason: "trusted-mute",
|
||||
videoId: videoId || null,
|
||||
pubkey: authorHex || authorPubkey || null,
|
||||
trustedMuteCount,
|
||||
});
|
||||
}
|
||||
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -319,6 +319,11 @@ export class ModerationService {
|
||||
this.userBlocks = userBlockManager;
|
||||
this.accessControl = accessControlService;
|
||||
|
||||
this.viewerMuteList = new Set();
|
||||
this.viewerMuteEventId = "";
|
||||
this.viewerMuteUpdatedAt = 0;
|
||||
this.viewerMutePromise = null;
|
||||
|
||||
this.contactSubscription = null;
|
||||
this.contactListPromise = null;
|
||||
|
||||
@@ -327,6 +332,10 @@ export class ModerationService {
|
||||
this.activeSubscriptions = new Map();
|
||||
this.activeEventIds = new Set();
|
||||
|
||||
this.trustedMuteLists = new Map();
|
||||
this.trustedMutedAuthors = new Map();
|
||||
this.trustedMuteSubscriptions = new Map();
|
||||
|
||||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||||
try {
|
||||
this.log(message, error);
|
||||
@@ -366,6 +375,74 @@ export class ModerationService {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
clearTrustedMuteTracking() {
|
||||
for (const [pubkey, entry] of this.trustedMuteSubscriptions.entries()) {
|
||||
if (entry && typeof entry.unsub === "function") {
|
||||
try {
|
||||
entry.unsub();
|
||||
} catch (error) {
|
||||
this.log(`(moderationService) failed to teardown mute subscription for ${pubkey}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.trustedMuteSubscriptions.clear();
|
||||
|
||||
for (const [author, aggregate] of this.trustedMutedAuthors.entries()) {
|
||||
if (aggregate && aggregate.muters instanceof Set) {
|
||||
aggregate.muters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
this.trustedMuteLists.clear();
|
||||
this.trustedMutedAuthors.clear();
|
||||
this.viewerMuteList.clear();
|
||||
this.viewerMuteEventId = "";
|
||||
this.viewerMuteUpdatedAt = 0;
|
||||
|
||||
this.emit("trusted-mutes", { total: 0 });
|
||||
}
|
||||
|
||||
isTrustedMuteOwner(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized === this.viewerPubkey) {
|
||||
return true;
|
||||
}
|
||||
return this.trustedContacts.has(normalized);
|
||||
}
|
||||
|
||||
reconcileTrustedMuteSubscriptions(previousSet, nextSet) {
|
||||
const previous = previousSet instanceof Set ? new Set(previousSet) : new Set();
|
||||
const next = nextSet instanceof Set ? new Set(nextSet) : new Set();
|
||||
|
||||
if (this.viewerPubkey) {
|
||||
if (this.trustedMuteSubscriptions.has(this.viewerPubkey)) {
|
||||
previous.add(this.viewerPubkey);
|
||||
}
|
||||
next.add(this.viewerPubkey);
|
||||
}
|
||||
|
||||
for (const value of previous) {
|
||||
if (!next.has(value)) {
|
||||
this.teardownTrustedMuteSubscription(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of next) {
|
||||
if (!previous.has(value)) {
|
||||
this.subscribeToTrustedMuteList(value).catch((error) => {
|
||||
this.log(
|
||||
`(moderationService) failed to subscribe to trusted mute list for ${value}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ensureUserBlocksLoaded(pubkey) {
|
||||
if (!this.userBlocks || typeof this.userBlocks.ensureLoaded !== "function") {
|
||||
return;
|
||||
@@ -383,6 +460,465 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToTrustedMuteList(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized || !this.isTrustedMuteOwner(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let record = this.trustedMuteSubscriptions.get(normalized);
|
||||
if (!record) {
|
||||
record = { unsub: null, promise: null };
|
||||
this.trustedMuteSubscriptions.set(normalized, record);
|
||||
} else if (record.promise) {
|
||||
try {
|
||||
await record.promise;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return;
|
||||
} else if (typeof record.unsub === "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
record.promise = (async () => {
|
||||
try {
|
||||
await this.ensurePool();
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`(moderationService) ensurePool failed while subscribing to trusted mute list for ${normalized}`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isTrustedMuteOwner(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient);
|
||||
if (!relays.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = { kinds: [10000], authors: [normalized], limit: 1 };
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await this.nostrClient.pool.list(relays, [filter]);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`(moderationService) failed to backfill trusted mute list for ${normalized}`,
|
||||
error,
|
||||
);
|
||||
events = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(events) && events.length) {
|
||||
let latest = null;
|
||||
for (const event of events) {
|
||||
if (!event || ensureNumber(event.created_at) <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || ensureNumber(event.created_at) > ensureNumber(latest.created_at)) {
|
||||
latest = event;
|
||||
}
|
||||
}
|
||||
if (latest) {
|
||||
this.ingestTrustedMuteEvent(latest);
|
||||
}
|
||||
} else {
|
||||
this.replaceTrustedMuteList(normalized, new Set(), { createdAt: 0, eventId: "" });
|
||||
}
|
||||
|
||||
if (!this.isTrustedMuteOwner(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sub = this.nostrClient.pool.sub(relays, [filter]);
|
||||
sub.on("event", (event) => {
|
||||
this.ingestTrustedMuteEvent(event);
|
||||
});
|
||||
sub.on("eose", () => {});
|
||||
record.unsub = typeof sub.unsub === "function" ? () => sub.unsub() : null;
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`(moderationService) failed to subscribe to trusted mute list for ${normalized}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await record.promise;
|
||||
} catch (error) {
|
||||
this.log("[moderationService] trusted mute subscription promise rejected", error);
|
||||
} finally {
|
||||
record.promise = null;
|
||||
}
|
||||
}
|
||||
|
||||
teardownTrustedMuteSubscription(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = this.trustedMuteSubscriptions.get(normalized);
|
||||
if (record && typeof record.unsub === "function") {
|
||||
try {
|
||||
record.unsub();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to teardown trusted mute subscription", error);
|
||||
}
|
||||
}
|
||||
this.trustedMuteSubscriptions.delete(normalized);
|
||||
this.replaceTrustedMuteList(normalized, new Set(), { createdAt: 0, eventId: "" });
|
||||
}
|
||||
|
||||
replaceTrustedMuteList(ownerPubkey, mutedAuthors, { createdAt = 0, eventId = "" } = {}) {
|
||||
const owner = normalizeHex(ownerPubkey);
|
||||
if (!owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedAuthors = new Set();
|
||||
if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) {
|
||||
for (const candidate of mutedAuthors) {
|
||||
const normalized = normalizeToHex(candidate);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
sanitizedAuthors.add(normalized);
|
||||
}
|
||||
} else if (mutedAuthors && typeof mutedAuthors[Symbol.iterator] === "function") {
|
||||
for (const candidate of mutedAuthors) {
|
||||
const normalized = normalizeToHex(candidate);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
sanitizedAuthors.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const previous = this.trustedMuteLists.get(owner);
|
||||
if (previous && previous.authors instanceof Set) {
|
||||
for (const author of previous.authors) {
|
||||
const aggregate = this.trustedMutedAuthors.get(author);
|
||||
if (!aggregate || !(aggregate.muters instanceof Set)) {
|
||||
continue;
|
||||
}
|
||||
aggregate.muters.delete(owner);
|
||||
if (!aggregate.muters.size) {
|
||||
this.trustedMutedAuthors.delete(author);
|
||||
} else {
|
||||
aggregate.count = aggregate.muters.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEventId = normalizeEventId(eventId);
|
||||
const normalizedCreatedAt = ensureNumber(createdAt);
|
||||
|
||||
if (owner === this.viewerPubkey) {
|
||||
this.viewerMuteList = new Set(sanitizedAuthors);
|
||||
this.viewerMuteEventId = normalizedEventId || "";
|
||||
this.viewerMuteUpdatedAt = normalizedCreatedAt;
|
||||
}
|
||||
|
||||
if (!sanitizedAuthors.size) {
|
||||
this.trustedMuteLists.delete(owner);
|
||||
} else {
|
||||
this.trustedMuteLists.set(owner, {
|
||||
authors: sanitizedAuthors,
|
||||
updatedAt: normalizedCreatedAt,
|
||||
eventId: normalizedEventId,
|
||||
});
|
||||
|
||||
for (const author of sanitizedAuthors) {
|
||||
let aggregate = this.trustedMutedAuthors.get(author);
|
||||
if (!aggregate) {
|
||||
aggregate = { muters: new Set(), count: 0 };
|
||||
this.trustedMutedAuthors.set(author, aggregate);
|
||||
}
|
||||
aggregate.muters.add(owner);
|
||||
aggregate.count = aggregate.muters.size;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit("trusted-mutes", { total: this.trustedMutedAuthors.size, owner });
|
||||
}
|
||||
|
||||
applyTrustedMuteEvent(ownerPubkey, event) {
|
||||
const owner = normalizeHex(ownerPubkey);
|
||||
if (!owner || !this.isTrustedMuteOwner(owner)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || event.kind !== 10000) {
|
||||
this.replaceTrustedMuteList(owner, new Set(), { createdAt: 0, eventId: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = ensureNumber(event.created_at);
|
||||
const eventId = typeof event.id === "string" ? event.id : "";
|
||||
|
||||
const existing = this.trustedMuteLists.get(owner);
|
||||
if (existing) {
|
||||
const existingEventId = typeof existing.eventId === "string" ? existing.eventId : "";
|
||||
if (existingEventId && normalizeEventId(existingEventId) === normalizeEventId(eventId)) {
|
||||
return;
|
||||
}
|
||||
if (ensureNumber(existing.updatedAt) > createdAt && normalizeEventId(eventId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mutedAuthors = new Set();
|
||||
if (Array.isArray(event.tags)) {
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (tag[0] !== "p") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeToHex(tag[1]);
|
||||
if (normalized) {
|
||||
mutedAuthors.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.replaceTrustedMuteList(owner, mutedAuthors, { createdAt, eventId });
|
||||
}
|
||||
|
||||
ingestTrustedMuteEvent(event) {
|
||||
if (!event || event.kind !== 10000) {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = normalizeHex(event.pubkey);
|
||||
if (!owner || !this.isTrustedMuteOwner(owner)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyTrustedMuteEvent(owner, event);
|
||||
}
|
||||
|
||||
getViewerMutedAuthors() {
|
||||
return Array.from(this.viewerMuteList);
|
||||
}
|
||||
|
||||
isAuthorMutedByViewer(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return this.viewerMuteList.has(normalized);
|
||||
}
|
||||
|
||||
async ensureViewerMuteListLoaded(pubkey = this.viewerPubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
this.viewerMuteList.clear();
|
||||
this.viewerMuteEventId = "";
|
||||
this.viewerMuteUpdatedAt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.trustedMuteLists.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewerMutePromise) {
|
||||
try {
|
||||
await this.viewerMutePromise;
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewerMutePromise = this.subscribeToTrustedMuteList(normalized)
|
||||
.catch((error) => {
|
||||
this.log("[moderationService] failed to load viewer mute list", error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.viewerMutePromise = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.viewerMutePromise;
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
async publishViewerMuteList({ owner, muted }) {
|
||||
const viewer = normalizeHex(owner || this.viewerPubkey);
|
||||
if (!viewer) {
|
||||
const error = new Error("viewer-not-logged-in");
|
||||
error.code = "viewer-not-logged-in";
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.ensurePool();
|
||||
|
||||
const extension = typeof window !== "undefined" ? window.nostr : null;
|
||||
if (!extension || typeof extension.signEvent !== "function") {
|
||||
const error = new Error("nostr-extension-missing");
|
||||
error.code = "nostr-extension-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (typeof this.nostrClient?.ensureExtensionPermissions === "function") {
|
||||
const permissionResult = await this.nostrClient.ensureExtensionPermissions([
|
||||
"sign_event",
|
||||
"get_public_key",
|
||||
]);
|
||||
if (!permissionResult?.ok) {
|
||||
const error = new Error("extension-permission-denied");
|
||||
error.code = "extension-permission-denied";
|
||||
error.details = permissionResult?.error || null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const tags = [];
|
||||
if (muted instanceof Set || Array.isArray(muted)) {
|
||||
for (const value of muted) {
|
||||
const normalized = normalizeHex(value);
|
||||
if (!normalized || normalized === viewer) {
|
||||
continue;
|
||||
}
|
||||
tags.push(["p", normalized]);
|
||||
}
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 10000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: "",
|
||||
pubkey: viewer,
|
||||
};
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await extension.signEvent(event);
|
||||
} catch (error) {
|
||||
const wrapped = new Error("signature-failed");
|
||||
wrapped.code = "signature-failed";
|
||||
wrapped.details = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient, { write: true });
|
||||
if (!relays.length) {
|
||||
const error = new Error("no-relays-configured");
|
||||
error.code = "no-relays-configured";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent);
|
||||
assertAnyRelayAccepted(results, { context: "mute list" });
|
||||
} catch (error) {
|
||||
const wrapped = new Error("mute-list-publish-failed");
|
||||
wrapped.code = "mute-list-publish-failed";
|
||||
wrapped.details = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
this.applyTrustedMuteEvent(viewer, signedEvent);
|
||||
return { ok: true, event: signedEvent, results };
|
||||
}
|
||||
|
||||
async addAuthorToViewerMuteList(pubkey) {
|
||||
const viewer = normalizeHex(this.viewerPubkey);
|
||||
if (!viewer) {
|
||||
const error = new Error("viewer-not-logged-in");
|
||||
error.code = "viewer-not-logged-in";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const target = normalizeHex(pubkey);
|
||||
if (!target) {
|
||||
const error = new Error("invalid-target");
|
||||
error.code = "invalid-target";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (target === viewer) {
|
||||
const error = new Error("self");
|
||||
error.code = "self";
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.ensureViewerMuteListLoaded(viewer);
|
||||
|
||||
if (this.viewerMuteList.has(target)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const next = new Set(this.viewerMuteList);
|
||||
next.add(target);
|
||||
|
||||
return this.publishViewerMuteList({ owner: viewer, muted: next });
|
||||
}
|
||||
|
||||
async removeAuthorFromViewerMuteList(pubkey) {
|
||||
const viewer = normalizeHex(this.viewerPubkey);
|
||||
if (!viewer) {
|
||||
const error = new Error("viewer-not-logged-in");
|
||||
error.code = "viewer-not-logged-in";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const target = normalizeHex(pubkey);
|
||||
if (!target) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
await this.ensureViewerMuteListLoaded(viewer);
|
||||
|
||||
if (!this.viewerMuteList.has(target)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const next = new Set(this.viewerMuteList);
|
||||
next.delete(target);
|
||||
|
||||
return this.publishViewerMuteList({ owner: viewer, muted: next });
|
||||
}
|
||||
|
||||
isAuthorMutedByTrusted(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const entry = this.trustedMutedAuthors.get(normalized);
|
||||
return Boolean(entry && entry.muters instanceof Set && entry.muters.size > 0);
|
||||
}
|
||||
|
||||
getTrustedMutersForAuthor(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entry = this.trustedMutedAuthors.get(normalized);
|
||||
if (!entry || !(entry.muters instanceof Set)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(entry.muters);
|
||||
}
|
||||
|
||||
isPubkeyBlockedByViewer(pubkey) {
|
||||
if (!this.userBlocks || typeof this.userBlocks.isBlocked !== "function") {
|
||||
return false;
|
||||
@@ -562,6 +1098,7 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
this.viewerPubkey = normalized;
|
||||
this.clearTrustedMuteTracking();
|
||||
this.trustedContacts.clear();
|
||||
this.recomputeAllSummaries();
|
||||
|
||||
@@ -599,12 +1136,16 @@ export class ModerationService {
|
||||
});
|
||||
|
||||
await this.ensureUserBlocksLoaded(normalized);
|
||||
await this.ensureViewerMuteListLoaded(normalized);
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
|
||||
async fetchTrustedContacts(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
const previousContacts =
|
||||
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
|
||||
if (!normalized) {
|
||||
this.clearTrustedMuteTracking();
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
return;
|
||||
@@ -619,6 +1160,7 @@ export class ModerationService {
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient);
|
||||
if (!relays.length) {
|
||||
this.clearTrustedMuteTracking();
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
return;
|
||||
@@ -635,9 +1177,11 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
this.clearTrustedMuteTracking();
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
this.recomputeAllSummaries();
|
||||
this.reconcileTrustedMuteSubscriptions(previousContacts, new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -651,14 +1195,17 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
this.applyContactEvent(latest);
|
||||
this.applyContactEvent(latest, { previous: previousContacts });
|
||||
}
|
||||
|
||||
applyContactEvent(event) {
|
||||
applyContactEvent(event, { previous = null } = {}) {
|
||||
const previousContacts = previous instanceof Set ? previous : new Set(this.trustedContacts);
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
this.clearTrustedMuteTracking();
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
this.recomputeAllSummaries();
|
||||
this.reconcileTrustedMuteSubscriptions(previousContacts, new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -676,6 +1223,7 @@ export class ModerationService {
|
||||
this.trustedContacts = nextSet;
|
||||
this.emit("contacts", { size: nextSet.size });
|
||||
this.recomputeAllSummaries();
|
||||
this.reconcileTrustedMuteSubscriptions(previousContacts, nextSet);
|
||||
}
|
||||
|
||||
async subscribeToContactList(pubkey) {
|
||||
|
||||
+144
-24
@@ -952,6 +952,24 @@ export class VideoCard {
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const trustedMuted = moderation?.trustedMuted === true;
|
||||
let trustedMuteCount = Number.isFinite(moderation?.trustedMuteCount)
|
||||
? Math.max(0, Math.floor(moderation.trustedMuteCount))
|
||||
: 0;
|
||||
|
||||
if (!trustedMuteCount && Array.isArray(moderation?.trustedMuters)) {
|
||||
const muters = moderation.trustedMuters
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean);
|
||||
trustedMuteCount = muters.length;
|
||||
}
|
||||
|
||||
const trustedMuteDisplayNames = Array.isArray(moderation?.trustedMuterDisplayNames)
|
||||
? moderation.trustedMuterDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const original =
|
||||
moderation?.original && typeof moderation.original === "object"
|
||||
? moderation.original
|
||||
@@ -962,6 +980,9 @@ export class VideoCard {
|
||||
friendlyType: reportType ? reportType.replace(/[_-]+/g, " ").trim() : "",
|
||||
trustedCount,
|
||||
reporterDisplayNames,
|
||||
trustedMuted,
|
||||
trustedMuteCount,
|
||||
trustedMuteDisplayNames,
|
||||
originalBlur: original.blurThumbnail === true,
|
||||
originalBlockAutoplay: original.blockAutoplay === true,
|
||||
activeBlur: moderation?.blurThumbnail === true,
|
||||
@@ -973,8 +994,11 @@ export class VideoCard {
|
||||
context.originalBlur ||
|
||||
context.originalBlockAutoplay ||
|
||||
context.trustedCount > 0 ||
|
||||
context.trustedMuted ||
|
||||
context.overrideActive;
|
||||
|
||||
context.allowOverride = context.originalBlur || context.originalBlockAutoplay;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -983,15 +1007,29 @@ export class VideoCard {
|
||||
return "";
|
||||
}
|
||||
|
||||
const typeLabel = context.friendlyType || "this video";
|
||||
const count = Math.max(0, Number(context.trustedCount) || 0);
|
||||
const reasons = [];
|
||||
|
||||
if (count <= 0) {
|
||||
return context.friendlyType ? `reports of ${typeLabel}` : "reports";
|
||||
if (context.trustedMuted) {
|
||||
const muteCount = Math.max(1, Number(context.trustedMuteCount) || 0);
|
||||
const muteLabel = muteCount === 1 ? "trusted contact" : "trusted contacts";
|
||||
reasons.push(`muted by ${muteCount === 1 ? "a" : muteCount} ${muteLabel}`);
|
||||
}
|
||||
|
||||
const friendLabel = count === 1 ? "friend" : "friends";
|
||||
return `${count} ${friendLabel} reported ${typeLabel}`;
|
||||
const typeLabel = context.friendlyType || "this video";
|
||||
const reportCount = Math.max(0, Number(context.trustedCount) || 0);
|
||||
if (reportCount > 0) {
|
||||
const friendLabel = reportCount === 1 ? "friend" : "friends";
|
||||
reasons.push(`${reportCount} ${friendLabel} reported ${typeLabel}`);
|
||||
} else if (!context.trustedMuted) {
|
||||
reasons.push(context.friendlyType ? `reports of ${typeLabel}` : "reports");
|
||||
}
|
||||
|
||||
if (!reasons.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const combined = reasons.join(" · ");
|
||||
return combined.charAt(0).toUpperCase() + combined.slice(1);
|
||||
}
|
||||
|
||||
buildModerationBadgeText(context) {
|
||||
@@ -1014,7 +1052,9 @@ export class VideoCard {
|
||||
if (context.originalBlockAutoplay) {
|
||||
parts.push("Autoplay blocked");
|
||||
}
|
||||
if (reason) {
|
||||
if (context.trustedMuted && !context.originalBlur && !context.originalBlockAutoplay) {
|
||||
parts.push(reason || "Muted by trusted contacts");
|
||||
} else if (reason) {
|
||||
parts.push(reason);
|
||||
}
|
||||
|
||||
@@ -1137,7 +1177,12 @@ export class VideoCard {
|
||||
});
|
||||
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
|
||||
badge.dataset.moderationBadge = "true";
|
||||
badge.dataset.moderationState = context.overrideActive ? "override" : "blocked";
|
||||
const state = context.overrideActive
|
||||
? "override"
|
||||
: context.trustedMuted
|
||||
? "trusted-mute"
|
||||
: "blocked";
|
||||
badge.dataset.moderationState = state;
|
||||
|
||||
const badgeId = this.getModerationBadgeId();
|
||||
badge.id = badgeId;
|
||||
@@ -1151,14 +1196,39 @@ export class VideoCard {
|
||||
});
|
||||
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}.`,
|
||||
);
|
||||
const muteNames = Array.isArray(context.trustedMuteDisplayNames)
|
||||
? context.trustedMuteDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const reporterNames = Array.isArray(context.reporterDisplayNames)
|
||||
? context.reporterDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const allNames = [...muteNames, ...reporterNames];
|
||||
const uniqueNames = [];
|
||||
const seenNameKeys = new Set();
|
||||
for (const name of allNames) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
continue;
|
||||
}
|
||||
const key = name.trim().toLowerCase();
|
||||
if (seenNameKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNameKeys.add(key);
|
||||
uniqueNames.push(name.trim());
|
||||
}
|
||||
|
||||
if (uniqueNames.length) {
|
||||
const joined = uniqueNames.join(", ");
|
||||
const hasMutedNames = muteNames.length > 0;
|
||||
const hasReporterNames = reporterNames.length > 0;
|
||||
const prefix = hasMutedNames && hasReporterNames ? "Muted/Reported by" : hasMutedNames ? "Muted by" : "Reported by";
|
||||
badge.title = `${prefix} ${joined}`;
|
||||
badge.setAttribute("aria-label", `${text.textContent}. ${prefix} ${joined}.`);
|
||||
} else {
|
||||
badge.removeAttribute("title");
|
||||
badge.setAttribute("aria-label", `${text.textContent}.`);
|
||||
@@ -1167,7 +1237,7 @@ export class VideoCard {
|
||||
this.moderationBadgeEl = badge;
|
||||
this.moderationBadgeTextEl = text;
|
||||
|
||||
if (!context.overrideActive) {
|
||||
if (!context.overrideActive && context.allowOverride) {
|
||||
const button = this.createModerationOverrideButton();
|
||||
badge.appendChild(button);
|
||||
this.moderationActionButton = button;
|
||||
@@ -1227,24 +1297,57 @@ export class VideoCard {
|
||||
}
|
||||
|
||||
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
|
||||
badge.dataset.moderationState = context.overrideActive ? "override" : "blocked";
|
||||
const state = context.overrideActive
|
||||
? "override"
|
||||
: context.trustedMuted
|
||||
? "trusted-mute"
|
||||
: "blocked";
|
||||
badge.dataset.moderationState = state;
|
||||
|
||||
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}.`);
|
||||
const muteNames = Array.isArray(context.trustedMuteDisplayNames)
|
||||
? context.trustedMuteDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const reporterNames = Array.isArray(context.reporterDisplayNames)
|
||||
? context.reporterDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const allNames = [...muteNames, ...reporterNames];
|
||||
const uniqueNames = [];
|
||||
const seenNameKeys = new Set();
|
||||
for (const name of allNames) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
continue;
|
||||
}
|
||||
const key = name.trim().toLowerCase();
|
||||
if (seenNameKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNameKeys.add(key);
|
||||
uniqueNames.push(name.trim());
|
||||
}
|
||||
|
||||
if (uniqueNames.length) {
|
||||
const joined = uniqueNames.join(", ");
|
||||
const hasMutedNames = muteNames.length > 0;
|
||||
const hasReporterNames = reporterNames.length > 0;
|
||||
const prefix = hasMutedNames && hasReporterNames ? "Muted/Reported by" : hasMutedNames ? "Muted by" : "Reported by";
|
||||
badge.title = `${prefix} ${joined}`;
|
||||
badge.setAttribute("aria-label", `${textContent}. ${prefix} ${joined}.`);
|
||||
} else {
|
||||
badge.removeAttribute("title");
|
||||
badge.setAttribute("aria-label", `${textContent}.`);
|
||||
}
|
||||
|
||||
if (context.overrideActive) {
|
||||
if (context.overrideActive || !context.allowOverride) {
|
||||
if (this.moderationActionButton) {
|
||||
this.moderationActionButton.removeEventListener(
|
||||
"click",
|
||||
@@ -1779,6 +1882,23 @@ export class VideoCard {
|
||||
delete this.root.dataset.moderationReportCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (moderationContext.trustedMuted) {
|
||||
this.root.dataset.moderationTrustedMute = "true";
|
||||
const muteCount = Math.max(0, Number(moderationContext.trustedMuteCount) || 0);
|
||||
if (muteCount > 0) {
|
||||
this.root.dataset.moderationTrustedMuteCount = String(muteCount);
|
||||
} else if (this.root.dataset.moderationTrustedMuteCount) {
|
||||
delete this.root.dataset.moderationTrustedMuteCount;
|
||||
}
|
||||
} else {
|
||||
if (this.root.dataset.moderationTrustedMute) {
|
||||
delete this.root.dataset.moderationTrustedMute;
|
||||
}
|
||||
if (this.root.dataset.moderationTrustedMuteCount) {
|
||||
delete this.root.dataset.moderationTrustedMuteCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyPlaybackDatasets() {
|
||||
|
||||
@@ -257,6 +257,18 @@ export function createVideoMoreMenuPanel({
|
||||
});
|
||||
}
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Mute creator",
|
||||
action: "mute-author",
|
||||
dataset: { author: metadata.pubkey, context },
|
||||
});
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Unmute creator",
|
||||
action: "unmute-author",
|
||||
dataset: { author: metadata.pubkey, context },
|
||||
});
|
||||
|
||||
if (canManageBlacklist) {
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Blacklist creator",
|
||||
@@ -307,6 +319,18 @@ export function createChannelProfileMenuPanel({
|
||||
dataset: { context: normalizedContext },
|
||||
});
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Mute channel",
|
||||
action: "mute-author",
|
||||
dataset: { context: normalizedContext },
|
||||
});
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Unmute channel",
|
||||
action: "unmute-author",
|
||||
dataset: { context: normalizedContext },
|
||||
});
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Blacklist channel",
|
||||
action: "blacklist-author",
|
||||
|
||||
@@ -79,6 +79,7 @@ export default class MoreMenuController {
|
||||
this.boundVideoListBlacklistHandler = null;
|
||||
this.popoversByTrigger = new Map();
|
||||
this.activePopover = null;
|
||||
this.activePopoverEntry = null;
|
||||
|
||||
this.reportModal = null;
|
||||
this.reportModalPrepared = false;
|
||||
@@ -582,6 +583,105 @@ export default class MoreMenuController {
|
||||
);
|
||||
}
|
||||
|
||||
setButtonVisibility(button, visible) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
if (visible) {
|
||||
button.removeAttribute("hidden");
|
||||
button.setAttribute("aria-hidden", "false");
|
||||
} else {
|
||||
button.setAttribute("hidden", "");
|
||||
button.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
|
||||
syncMuteMenuButtons(panel, entry = {}) {
|
||||
if (!panel || typeof panel.querySelector !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const muteButton = panel.querySelector('button[data-action="mute-author"]');
|
||||
const unmuteButton = panel.querySelector('button[data-action="unmute-author"]');
|
||||
if (!muteButton && !unmuteButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextVideo = entry?.context?.video || null;
|
||||
let authorCandidate = contextVideo?.pubkey || "";
|
||||
|
||||
if (!authorCandidate) {
|
||||
if (muteButton?.dataset?.author) {
|
||||
authorCandidate = muteButton.dataset.author;
|
||||
} else if (unmuteButton?.dataset?.author) {
|
||||
authorCandidate = unmuteButton.dataset.author;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = typeof authorCandidate === "string" ? authorCandidate.trim() : "";
|
||||
let normalized = "";
|
||||
if (trimmed) {
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
normalized = trimmed.toLowerCase();
|
||||
} else if (trimmed.startsWith("npub1")) {
|
||||
normalized = this.callbacks.safeDecodeNpub(trimmed) || "";
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized) {
|
||||
if (muteButton) {
|
||||
muteButton.dataset.author = normalized;
|
||||
}
|
||||
if (unmuteButton) {
|
||||
unmuteButton.dataset.author = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
let isMuted = false;
|
||||
if (normalized) {
|
||||
try {
|
||||
isMuted = this.moderationService.isAuthorMutedByViewer(normalized) === true;
|
||||
} catch (error) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn("[MoreMenu] Failed to resolve viewer mute state", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasTarget = Boolean(normalized);
|
||||
this.setButtonVisibility(muteButton, hasTarget && !isMuted);
|
||||
this.setButtonVisibility(unmuteButton, hasTarget && isMuted);
|
||||
}
|
||||
|
||||
refreshActiveMuteButtons({ author = "" } = {}) {
|
||||
if (!this.activePopoverEntry || !this.activePopover) {
|
||||
return;
|
||||
}
|
||||
const panel =
|
||||
typeof this.activePopover.getPanel === "function"
|
||||
? this.activePopover.getPanel()
|
||||
: null;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (author) {
|
||||
const trimmed = author.trim();
|
||||
if (trimmed) {
|
||||
const muteButton = panel.querySelector('button[data-action="mute-author"]');
|
||||
const unmuteButton = panel.querySelector('button[data-action="unmute-author"]');
|
||||
if (muteButton) {
|
||||
muteButton.dataset.author = trimmed;
|
||||
}
|
||||
if (unmuteButton) {
|
||||
unmuteButton.dataset.author = trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.syncMuteMenuButtons(panel, this.activePopoverEntry);
|
||||
}
|
||||
|
||||
createPopoverRender(entry) {
|
||||
return ({ document: documentRef, close }) => {
|
||||
const panel = createVideoMoreMenuPanel({
|
||||
@@ -599,6 +699,8 @@ export default class MoreMenuController {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.syncMuteMenuButtons(panel, entry);
|
||||
|
||||
const buttons = panel.querySelectorAll("button[data-action]");
|
||||
buttons.forEach((button) => {
|
||||
const HTMLElementCtor = this.getHTMLElementConstructor(button?.ownerDocument);
|
||||
@@ -656,6 +758,12 @@ export default class MoreMenuController {
|
||||
const result = await originalOpen(...args);
|
||||
if (result) {
|
||||
this.activePopover = popover;
|
||||
this.activePopoverEntry = entry;
|
||||
const panel =
|
||||
typeof popover.getPanel === "function" ? popover.getPanel() : null;
|
||||
if (panel) {
|
||||
this.syncMuteMenuButtons(panel, entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -670,6 +778,7 @@ export default class MoreMenuController {
|
||||
const result = originalClose({ restoreFocus });
|
||||
if (result && this.activePopover === popover) {
|
||||
this.activePopover = null;
|
||||
this.activePopoverEntry = null;
|
||||
}
|
||||
if (result && typeof entry?.context?.onClose === "function") {
|
||||
try {
|
||||
@@ -692,6 +801,7 @@ export default class MoreMenuController {
|
||||
popover.destroy = (...args) => {
|
||||
if (this.activePopover === popover) {
|
||||
this.activePopover = null;
|
||||
this.activePopoverEntry = null;
|
||||
}
|
||||
originalDestroy(...args);
|
||||
if (trigger && this.popoversByTrigger.get(trigger) === entry) {
|
||||
@@ -942,6 +1052,36 @@ export default class MoreMenuController {
|
||||
const context = dataset.context || "";
|
||||
const currentVideo = this.callbacks.getCurrentVideo();
|
||||
|
||||
const resolveTargetAuthorHex = () => {
|
||||
const candidates = [
|
||||
dataset.author,
|
||||
dataset.npub,
|
||||
context === "modal" ? currentVideo?.pubkey : "",
|
||||
currentVideo?.pubkey,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = this.callbacks.safeDecodeNpub(trimmed);
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
switch (normalized) {
|
||||
case "open-channel": {
|
||||
if (context === "modal") {
|
||||
@@ -1028,6 +1168,156 @@ export default class MoreMenuController {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "mute-author": {
|
||||
const viewerPubkey = this.callbacks.getCurrentUserPubkey();
|
||||
if (!viewerPubkey) {
|
||||
this.callbacks.showError("Please login to manage your mute list.");
|
||||
break;
|
||||
}
|
||||
|
||||
const targetHex = resolveTargetAuthorHex();
|
||||
if (!targetHex) {
|
||||
this.callbacks.showError("Unable to determine the creator to mute.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (targetHex === viewerPubkey) {
|
||||
this.callbacks.showError("You cannot mute yourself.");
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.moderationService.addAuthorToViewerMuteList(
|
||||
targetHex,
|
||||
);
|
||||
if (result?.already) {
|
||||
this.callbacks.showSuccess("You already muted this creator.");
|
||||
} else {
|
||||
this.callbacks.showSuccess(
|
||||
"Creator muted. Their videos will be downranked in your feeds.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.callbacks.loadVideos();
|
||||
} catch (loadError) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn("[MoreMenu] Failed to reload videos after muting", loadError);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.subscriptions?.refreshActiveFeed?.({
|
||||
reason: "viewer-mute-update",
|
||||
});
|
||||
} catch (refreshError) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn(
|
||||
"[Subscriptions] Failed to refresh after viewer mute update:",
|
||||
refreshError,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.refreshActiveMuteButtons({ author: targetHex });
|
||||
} catch (error) {
|
||||
switch (error?.code) {
|
||||
case "viewer-not-logged-in":
|
||||
this.callbacks.showError("Please login to manage your mute list.");
|
||||
break;
|
||||
case "invalid-target":
|
||||
this.callbacks.showError("Unable to determine the creator to mute.");
|
||||
break;
|
||||
case "self":
|
||||
this.callbacks.showError("You cannot mute yourself.");
|
||||
break;
|
||||
case "nostr-extension-missing":
|
||||
this.callbacks.showError(
|
||||
"A Nostr extension is required to update your mute list.",
|
||||
);
|
||||
break;
|
||||
case "extension-permission-denied":
|
||||
this.callbacks.showError(
|
||||
"Allow your Nostr extension to sign events before muting creators.",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.callbacks.showError(
|
||||
"Failed to update your mute list. Please try again.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "unmute-author": {
|
||||
const viewerPubkey = this.callbacks.getCurrentUserPubkey();
|
||||
if (!viewerPubkey) {
|
||||
this.callbacks.showError("Please login to manage your mute list.");
|
||||
break;
|
||||
}
|
||||
|
||||
const targetHex = resolveTargetAuthorHex();
|
||||
if (!targetHex) {
|
||||
this.callbacks.showError("Unable to determine the creator to unmute.");
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.moderationService.removeAuthorFromViewerMuteList(
|
||||
targetHex,
|
||||
);
|
||||
if (result?.already) {
|
||||
this.callbacks.showSuccess("This creator is not on your mute list.");
|
||||
} else {
|
||||
this.callbacks.showSuccess(
|
||||
"Creator removed from your mute list.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.callbacks.loadVideos();
|
||||
} catch (loadError) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn(
|
||||
"[MoreMenu] Failed to reload videos after unmuting",
|
||||
loadError,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.subscriptions?.refreshActiveFeed?.({
|
||||
reason: "viewer-mute-update",
|
||||
});
|
||||
} catch (refreshError) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn(
|
||||
"[Subscriptions] Failed to refresh after viewer mute removal:",
|
||||
refreshError,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.refreshActiveMuteButtons({ author: targetHex });
|
||||
} catch (error) {
|
||||
switch (error?.code) {
|
||||
case "viewer-not-logged-in":
|
||||
this.callbacks.showError("Please login to manage your mute list.");
|
||||
break;
|
||||
case "nostr-extension-missing":
|
||||
this.callbacks.showError(
|
||||
"A Nostr extension is required to update your mute list.",
|
||||
);
|
||||
break;
|
||||
case "extension-permission-denied":
|
||||
this.callbacks.showError(
|
||||
"Allow your Nostr extension to sign events before updating your mute list.",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.callbacks.showError(
|
||||
"Failed to update your mute list. Please try again.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "blacklist-author": {
|
||||
const actorNpub = this.callbacks.getCurrentUserNpub();
|
||||
if (!actorNpub) {
|
||||
|
||||
Generated
+12
-12
@@ -14,7 +14,7 @@
|
||||
"webtorrent": "^1.9.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -910,13 +910,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
|
||||
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.56.0"
|
||||
"playwright": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -4103,13 +4103,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
|
||||
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.0"
|
||||
"playwright-core": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -4122,9 +4122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
|
||||
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
"test:unit": "node scripts/run-unit-tests.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"cross-env": "^7.0.3",
|
||||
|
||||
@@ -214,9 +214,35 @@ async function testBlacklistOrderingWithRuntimeChanges() {
|
||||
]);
|
||||
}
|
||||
|
||||
async function testTrustedMuteDownrank() {
|
||||
const sorter = createChronologicalSorter();
|
||||
|
||||
const items = [
|
||||
{
|
||||
video: { id: "muted", created_at: 400 },
|
||||
metadata: { moderation: { trustedMuted: true } },
|
||||
},
|
||||
{
|
||||
video: { id: "fresh", created_at: 500 },
|
||||
metadata: { moderation: { trustedMuted: false } },
|
||||
},
|
||||
{
|
||||
video: { id: "older", created_at: 300 },
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const sorted = sorter(items);
|
||||
assert.deepEqual(
|
||||
sorted.map((entry) => entry.video.id),
|
||||
["fresh", "older", "muted"],
|
||||
);
|
||||
}
|
||||
|
||||
await testDedupeOrdering();
|
||||
await testBlacklistFiltering();
|
||||
await testWatchHistoryHookIsolation();
|
||||
await testBlacklistOrderingWithRuntimeChanges();
|
||||
await testTrustedMuteDownrank();
|
||||
|
||||
console.log("All feed engine tests passed");
|
||||
@@ -100,3 +100,128 @@ test("trusted report summaries respect personal blocks and admin lists", async (
|
||||
);
|
||||
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
|
||||
});
|
||||
|
||||
test("trusted mute aggregation tracks F1 mute lists", () => {
|
||||
const service = new ModerationService({ logger: () => {} });
|
||||
|
||||
const contactA = "a".repeat(64);
|
||||
const contactB = "b".repeat(64);
|
||||
const mutedAuthor = "c".repeat(64);
|
||||
|
||||
service.trustedContacts = new Set([contactA, contactB]);
|
||||
|
||||
service.ingestTrustedMuteEvent({
|
||||
kind: 10000,
|
||||
pubkey: contactA,
|
||||
created_at: 100,
|
||||
id: "1".repeat(64),
|
||||
tags: [["p", mutedAuthor]],
|
||||
});
|
||||
|
||||
assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), true);
|
||||
assert.deepEqual(service.getTrustedMutersForAuthor(mutedAuthor), [contactA]);
|
||||
|
||||
service.ingestTrustedMuteEvent({
|
||||
kind: 10000,
|
||||
pubkey: contactB,
|
||||
created_at: 105,
|
||||
id: "2".repeat(64),
|
||||
tags: [["p", mutedAuthor]],
|
||||
});
|
||||
|
||||
const muters = new Set(service.getTrustedMutersForAuthor(mutedAuthor));
|
||||
assert.equal(muters.size, 2);
|
||||
assert(muters.has(contactA));
|
||||
assert(muters.has(contactB));
|
||||
|
||||
service.applyTrustedMuteEvent(contactA, {
|
||||
kind: 10000,
|
||||
pubkey: contactA,
|
||||
created_at: 200,
|
||||
id: "3".repeat(64),
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const remaining = service.getTrustedMutersForAuthor(mutedAuthor);
|
||||
assert.deepEqual(remaining, [contactB]);
|
||||
assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), true);
|
||||
|
||||
service.applyTrustedMuteEvent(contactB, {
|
||||
kind: 10000,
|
||||
pubkey: contactB,
|
||||
created_at: 210,
|
||||
id: "4".repeat(64),
|
||||
tags: [],
|
||||
});
|
||||
|
||||
assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), false);
|
||||
assert.deepEqual(service.getTrustedMutersForAuthor(mutedAuthor), []);
|
||||
});
|
||||
|
||||
test("viewer mute list publishes and updates aggregation", async (t) => {
|
||||
const publishCalls = [];
|
||||
const nostrClient = {
|
||||
pool: {
|
||||
list: async () => [],
|
||||
sub: () => ({
|
||||
on: () => {},
|
||||
unsub: () => {},
|
||||
}),
|
||||
publish: (urls, event) => {
|
||||
publishCalls.push({ urls, event });
|
||||
return {
|
||||
on: (eventName, handler) => {
|
||||
if (eventName === "ok") {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
relays: ["wss://relay.example"],
|
||||
ensurePool: async () => {},
|
||||
ensureExtensionPermissions: async () => ({ ok: true }),
|
||||
};
|
||||
|
||||
const service = new ModerationService({
|
||||
logger: () => {},
|
||||
nostrClient,
|
||||
});
|
||||
|
||||
const previousNostr = globalThis.window.nostr;
|
||||
globalThis.window.nostr = {
|
||||
async signEvent(event) {
|
||||
return { ...event, id: "signed-event-id" };
|
||||
},
|
||||
};
|
||||
|
||||
t.after(() => {
|
||||
globalThis.window.nostr = previousNostr;
|
||||
});
|
||||
|
||||
const viewerHex = "f".repeat(64);
|
||||
await service.setViewerPubkey(viewerHex);
|
||||
|
||||
const targetHex = "e".repeat(64);
|
||||
|
||||
await service.addAuthorToViewerMuteList(targetHex);
|
||||
|
||||
assert.equal(service.isAuthorMutedByViewer(targetHex), true);
|
||||
assert.equal(service.isAuthorMutedByTrusted(targetHex), true);
|
||||
assert.deepEqual(service.getTrustedMutersForAuthor(targetHex), [viewerHex]);
|
||||
|
||||
assert.equal(publishCalls.length, 1);
|
||||
const firstEvent = publishCalls[0].event;
|
||||
assert.equal(firstEvent.tags.length, 1);
|
||||
assert.deepEqual(firstEvent.tags[0], ["p", targetHex]);
|
||||
|
||||
await service.removeAuthorFromViewerMuteList(targetHex);
|
||||
|
||||
assert.equal(service.isAuthorMutedByViewer(targetHex), false);
|
||||
assert.equal(service.isAuthorMutedByTrusted(targetHex), false);
|
||||
assert.deepEqual(service.getTrustedMutersForAuthor(targetHex), []);
|
||||
|
||||
assert.equal(publishCalls.length, 2);
|
||||
const secondEvent = publishCalls[1].event;
|
||||
assert.equal(secondEvent.tags.length, 0);
|
||||
});
|
||||
@@ -103,3 +103,66 @@ test("moderation stage enforces admin lists and whitelist bypass", async () => {
|
||||
assert(reasons.includes("admin-blacklist"));
|
||||
assert(reasons.includes("blur"));
|
||||
});
|
||||
|
||||
test("moderation stage annotates trusted mute metadata", async () => {
|
||||
const mutedHex = "9".repeat(64);
|
||||
const muterHex = "8".repeat(64);
|
||||
|
||||
const service = {
|
||||
async refreshViewerFromClient() {},
|
||||
async setActiveEventIds() {},
|
||||
getAdminListSnapshot() {
|
||||
return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() };
|
||||
},
|
||||
getAccessControlStatus(identifier) {
|
||||
return { hex: identifier, whitelisted: false, blacklisted: false };
|
||||
},
|
||||
getTrustedReportSummary() {
|
||||
return null;
|
||||
},
|
||||
trustedReportCount() {
|
||||
return 0;
|
||||
},
|
||||
getTrustedReporters() {
|
||||
return [];
|
||||
},
|
||||
isAuthorMutedByTrusted(pubkey) {
|
||||
return pubkey === mutedHex;
|
||||
},
|
||||
getTrustedMutersForAuthor(pubkey) {
|
||||
return pubkey === mutedHex ? [muterHex] : [];
|
||||
},
|
||||
};
|
||||
|
||||
const stage = createModerationStage({ service });
|
||||
|
||||
const items = [
|
||||
{ video: { id: "muted", pubkey: mutedHex }, metadata: {} },
|
||||
{ video: { id: "normal", pubkey: "7".repeat(64) }, metadata: {} },
|
||||
];
|
||||
|
||||
const reasons = [];
|
||||
const context = {
|
||||
addWhy(detail) {
|
||||
reasons.push(detail);
|
||||
return detail;
|
||||
},
|
||||
log() {},
|
||||
};
|
||||
|
||||
const result = await stage(items, context);
|
||||
|
||||
assert.equal(result.length, 2);
|
||||
|
||||
const mutedItem = result.find((entry) => entry.video.id === "muted");
|
||||
assert.ok(mutedItem);
|
||||
assert.equal(mutedItem.video.moderation.trustedMuted, true);
|
||||
assert.deepEqual(mutedItem.video.moderation.trustedMuters, [muterHex]);
|
||||
assert.equal(mutedItem.video.moderation.trustedMuteCount, 1);
|
||||
assert.equal(mutedItem.metadata.moderation.trustedMuted, true);
|
||||
assert.equal(mutedItem.metadata.moderation.trustedMuteCount, 1);
|
||||
|
||||
const trustedMuteReason = reasons.find((entry) => entry.reason === "trusted-mute");
|
||||
assert.ok(trustedMuteReason);
|
||||
assert.equal(trustedMuteReason.videoId, "muted");
|
||||
});
|
||||
@@ -159,3 +159,103 @@ test('block-author updates user blocks, reloads videos, and refreshes feeds', as
|
||||
['refreshActiveFeed', { reason: 'user-block-update' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('mute-author updates viewer mute list and refreshes feeds', async () => {
|
||||
const events = [];
|
||||
const moderationStub = {
|
||||
addAuthorToViewerMuteList: async (target) => {
|
||||
events.push(['addMute', target]);
|
||||
return { ok: true };
|
||||
},
|
||||
removeAuthorFromViewerMuteList: async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
isAuthorMutedByViewer: () => false,
|
||||
};
|
||||
|
||||
const subscriptions = {
|
||||
refreshActiveFeed: async (args) => {
|
||||
events.push(['refreshActiveFeed', args]);
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new MoreMenuController({
|
||||
moderationService: moderationStub,
|
||||
subscriptions,
|
||||
callbacks: {
|
||||
getCurrentUserPubkey: () => 'viewerhex',
|
||||
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
|
||||
loadVideos: async () => {
|
||||
events.push(['loadVideos']);
|
||||
},
|
||||
showSuccess: (message) => {
|
||||
events.push(['success', message]);
|
||||
},
|
||||
showError: (message) => {
|
||||
events.push(['error', message]);
|
||||
},
|
||||
safeDecodeNpub: () => '',
|
||||
},
|
||||
});
|
||||
|
||||
const targetHex = 'd'.repeat(64);
|
||||
|
||||
await controller.handleMoreMenuAction('mute-author', { author: targetHex });
|
||||
|
||||
assert.deepEqual(events, [
|
||||
['addMute', targetHex],
|
||||
['success', 'Creator muted. Their videos will be downranked in your feeds.'],
|
||||
['loadVideos'],
|
||||
['refreshActiveFeed', { reason: 'viewer-mute-update' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('unmute-author removes creators from viewer mute list', async () => {
|
||||
const events = [];
|
||||
const moderationStub = {
|
||||
addAuthorToViewerMuteList: async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
removeAuthorFromViewerMuteList: async (target) => {
|
||||
events.push(['removeMute', target]);
|
||||
return { ok: true };
|
||||
},
|
||||
isAuthorMutedByViewer: () => true,
|
||||
};
|
||||
|
||||
const subscriptions = {
|
||||
refreshActiveFeed: async (args) => {
|
||||
events.push(['refreshActiveFeed', args]);
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new MoreMenuController({
|
||||
moderationService: moderationStub,
|
||||
subscriptions,
|
||||
callbacks: {
|
||||
getCurrentUserPubkey: () => 'viewerhex',
|
||||
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
|
||||
loadVideos: async () => {
|
||||
events.push(['loadVideos']);
|
||||
},
|
||||
showSuccess: (message) => {
|
||||
events.push(['success', message]);
|
||||
},
|
||||
showError: (message) => {
|
||||
events.push(['error', message]);
|
||||
},
|
||||
safeDecodeNpub: () => '',
|
||||
},
|
||||
});
|
||||
|
||||
const targetHex = 'e'.repeat(64);
|
||||
|
||||
await controller.handleMoreMenuAction('unmute-author', { author: targetHex });
|
||||
|
||||
assert.deepEqual(events, [
|
||||
['removeMute', targetHex],
|
||||
['success', 'Creator removed from your mute list.'],
|
||||
['loadVideos'],
|
||||
['refreshActiveFeed', { reason: 'viewer-mute-update' }],
|
||||
]);
|
||||
});
|
||||
@@ -97,4 +97,16 @@ test.describe("moderation fixtures", () => {
|
||||
reloadedCard.getByRole("button", { name: "Show anyway" })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("trusted mute fixture annotates mute reason", async ({ page }) => {
|
||||
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
|
||||
|
||||
const muteCard = page.locator('[data-test-id="trusted-mute"]');
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute", "true");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute-count", "1");
|
||||
await expect(muteCard).not.toHaveAttribute("data-moderation-override", "show-anyway");
|
||||
|
||||
const badge = muteCard.locator('[data-moderation-badge="true"]');
|
||||
await expect(badge).toContainText("Muted by a trusted contact");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user