Improve moderation restoration and mobile card actions

This commit is contained in:
thePR0M3TH3AN committed 2026-07-26 15:35:20 -04:00
1 parent 3735d57410
commit 4525c1c657
14 files changed
+455 -44

No files matched your search

+6 -5
View File
@@ -1029,7 +1029,7 @@
}
.moderation-badge {
@apply inline-flex flex-col items-start gap-sm rounded-xl border px-3 py-1.5 text-xs font-semibold normal-case tracking-tight shadow-sm;
@apply inline-flex max-w-full flex-col items-start gap-sm rounded-xl border px-3 py-1.5 text-xs font-semibold normal-case tracking-tight shadow-sm;
}
.moderation-badge--interactive {
@@ -1037,11 +1037,11 @@
}
.moderation-badge__actions {
@apply inline-flex flex-nowrap items-center gap-sm;
@apply flex max-w-full flex-wrap items-center gap-sm;
}
.moderation-badge__label {
@apply inline-flex items-center gap-xs whitespace-nowrap;
@apply inline-flex min-w-0 items-center gap-xs;
}
.moderation-badge[data-variant="warning"] {
@@ -1081,11 +1081,12 @@
}
.moderation-badge__text {
@apply whitespace-nowrap text-xs font-semibold tracking-tight;
@apply min-w-0 text-xs font-semibold tracking-tight;
}
.moderation-badge__action {
@apply inline-flex items-center gap-1.5 rounded-full border border-status-warning-border bg-status-warning-surface px-3 py-1 text-xs font-semibold normal-case tracking-tight text-status-warning-on transition duration-150 ease-out;
@apply inline-flex max-w-full items-center gap-1.5 rounded-full border border-status-warning-border bg-status-warning-surface px-3 py-1 text-xs font-semibold normal-case tracking-tight text-status-warning-on transition duration-150 ease-out;
overflow-wrap: anywhere;
background-color: color-mix(
in srgb,
var(--status-warning) 16%,
+1 -1
View File
@@ -36,7 +36,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
- The badge tooltip and `aria-label` continue to list the specific trusted contacts who muted or reported the content when that metadata is available.
- Viewers who override a trusted mute hide keep a **Block** pill on the same badge so they can block the creator without hunting through another menu.
- Selecting **Block** immediately publishes an account block for the creator, clears any temporary overrides, and reloads the feed so the author disappears from subsequent fetches.
- Trusted mute decisions now block autoplay even when report thresholds have not fired, and any associated avatars or channel banners adopt the blurred state alongside the primary thumbnail so the entire card reflects the moderation choice.
- Trusted mutes are a ranking-only signal until their hide threshold is reached. A single stale or bad-faith mute never blurs a creator, blocks playback, or adds a warning badge; feed sorters place those items later. At the configured mute-hide threshold, the card is hidden and its visuals are blurred like any other hide decision.
## Where to extend
Thread new moderation behaviors through the same service → stage → app → UI flow above. Extending the existing layers keeps overrides, feature flags, and QA hooks consistent—avoid spinning up parallel moderation modules unless the architecture document explicitly calls for it.
+1 -1
View File
@@ -56,7 +56,7 @@ Expected:
}
```
Expected: author’s items are downranked/blurred for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 20) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
Expected: author’s items are downranked for this viewer without blur, playback blocking, or a warning badge. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 20) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
**Exercises:** [`ModerationService.ingestTrustedMuteEvent()`](../../js/services/moderationService.js) updates [`ModerationService.isAuthorMutedByTrusted()`](../../js/services/moderationService.js) / [`getTrustedMutersForAuthor()`](../../js/services/moderationService.js), which [`createModerationStage()`](../../js/feedEngine/stages.js) propagates to the video item before [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js) renders the mute state.
+345
View File
@@ -0,0 +1,345 @@
# BitVid Performance & Efficiency Plan
Status: **planned — deeper system audit complete; no performance changes implemented**
## Goal
Reduce idle CPU, GPU, memory, relay traffic, and unnecessary network activity
without making feeds stale or weakening BitVid's Nostr-native recommendation
system.
BitVid must continue to ingest relevant notes, follows, zaps, watch events, and
moderation signals promptly. The optimization is about scheduling and bounded
work, not removing recommendation inputs or live feed updates.
## Baseline findings
Local observations on 2026-07-26 while serving the production build:
| Component | Observed load | Conclusion |
| --- | --- | --- |
| Static `serve dist` process | under 1% CPU, about 115 MB RSS | Not the heat source. |
| Browser main/renderer/GPU processes | renderer about 22% CPU, GPU about 7% CPU, renderer about 430 MB RSS | Primary area for profiling and improvement. |
| System memory | 24 GiB available; no swap in use | Not currently memory pressured. |
Code audit candidates:
- `ExploreDataService` starts for every session and refreshes watch-history tags
every minute and tag-IDF every five minutes, even outside Explore/For You.
- `devReqMonitor.js` automatically patches WebSocket sends on localhost and
wakes every two seconds. This is development-only but makes local testing
noisier and more expensive than production.
- App startup registers/checks the service worker and explicitly calls
`registration.update()` on every load.
- URL/torrent health checks can allocate media probes and WebTorrent tracker
work; they must remain viewport-driven, bounded, and cancellable.
- The video-modal ambient background can perform recurring canvas draws while a
video plays. It is a likely GPU hotspot and needs measurement before tuning.
- The image service-worker cache revalidates external images in the background;
warm feeds can still cause many network/cache operations.
## Deeper system audit
This table separates verified mechanics from hypotheses. It is deliberately not
a list of features to remove: every item has a product boundary that must stay
intact.
| System | Verified finding | Risk / opportunity | Planned boundary |
| --- | --- | --- | --- |
| Nostr subscriptions and feed loading | Feed loading deduplicates normal in-flight requests and active feeds need live relay updates. | Over-throttling would make Home, follows, zaps, and moderation stale. | Preserve subscriptions and event ingestion; coalesce only duplicate refresh/re-rank work. |
| For You / Explore indexes | `ExploreDataService` is constructed during bootstrap, clones the video set into a worker, refreshes history tags each minute and IDF each five minutes. Event, visibility, and timer paths can request the same broad work. | Unused views pay recurrent CPU, worker-message, and allocation cost. | Keep lightweight dirty/version signals live; create and refresh the worker only for an active or imminent recommendation view, with one serialized refresh per index. |
| Event persistence and relay cache | IndexedDB writes are differential and idle-scheduled, but the payload is built from the complete `allEvents` and tombstone maps; restore likewise rehydrates the complete unexpired cache. TTL expiry is 10 minutes, with no explicit record/byte budget. | Large relay sessions can raise startup, structured-clone/serialization, IndexedDB, and renderer-memory cost. | Measure actual record/byte growth first, then add an eviction budget that preserves active/latest, user-owned, watched, and moderation-relevant events. Never replace the cache with a smaller unscoped relay query. |
| URL liveness cards | The observer is viewport/prefetch-margin driven, and URL checks are deduplicated at controller level. Hidden-until-verified cards intentionally start an eager check so they can become visible. | A large hidden-card batch can still fill work queues; discarded cards cannot presently express cancellation through the generic observer. | Retain the reveal guarantee, but add queue cancellation, page/feed budgets, and primary-source-first fallback. |
| WebTorrent stream health | Torrent probes are globally queued (two at a time), cached, deduplicated, capped at four peer connections and destroy each probe torrent. A reusable probe client remains after first use. | The safeguards are good, but queue entries may outlive a discarded feed and a health badge can still pull WebTorrent into an otherwise hosted-video session. | Add cancellation/queue pruning and explicit health-probe telemetry before changing limits; evaluate lazy WebTorrent loading separately from playback correctness. |
| WebTorrent startup bundle | The WebTorrent facade imports the roughly 212 KB minified implementation at module evaluation time. | Browsers can download/parse a torrent stack before the viewer plays or probes a torrent. | Measure module transfer/parse on cold Home, then introduce a lazy implementation loader only if hosted playback and health-badge behavior remain correct. |
| Service worker and external images | Boot checks existing registrations and calls `update()`; the image strategy returns cache quickly but starts a network revalidation for each cached image request. | Warm feeds can produce avoidable fetch/cache churn even when their images are already fresh. | Keep deploy reliability and bounded cache trimming; add a tested freshness policy rather than disabling updates or image caching. |
| Modal visuals and UI rendering | The ambient-video helper schedules animation-frame callbacks while active, although its current modal wiring needs trace validation. Feed rerenders replace card DOM and attach health observers again. | Canvas work may be a GPU cost; observer lifecycle needs a retained-node/queue profile before calling it a leak. | Instrument active animation callbacks and observed-card counts. Tune only if a playback trace shows material cost; add explicit observer teardown if retained cards are confirmed. |
| Development diagnostics | On localhost, `devReqMonitor` patches WebSocket sends and wakes every two seconds. | It contaminates local CPU and relay measurements but is not a production heat source. | Make it opt-in so normal development reproduces production behavior. |
### What is *not* a candidate for removal
- Nostr notes, follows, zaps, reactions, watch events, and moderation signals
remain the raw material for feeds and recommendations.
- Active-feed subscriptions, source fallback, WebTorrent playback, and the
stale-while-revalidate cache remain product capabilities.
- A timer or worker is changed only after a trace identifies it as active in a
relevant scenario; no optimization is justified merely because code exists.
## Design rules
1. **Nostr ingestion stays live.** Keep subscriptions for active feeds and
logged-in viewer context. New relevant events update local indexes promptly.
2. **Expensive work is demand-driven.** A new event marks an index stale; it
does not force a whole recommendation rebuild until the associated feed is
active or about to become active.
3. **Inactive views do no recurring work.** Hidden tabs, closed modals, and
inactive feed views must pause timers, observers, and probe queues.
4. **Every background task has a bound.** Cap concurrency, use TTL caches,
cancel obsolete work, and destroy listeners/workers when their feature is no
longer in use.
5. **No silent product degradation.** Feed freshness, source fallback,
WebTorrent playback, moderation, and user-visible recommendation inputs are
acceptance criteria in every stage.
6. **Use cancellation, not just lower limits.** A task belonging to a removed
card, closed modal, inactive feed, or hidden document must be removable from
the queue; lowering concurrency alone can leave obsolete work running.
7. **Measure both wall time and retained state.** A faster task that retains a
large worker snapshot, observer target, cache record, or probe client is not
an efficiency win.
## Measurement harness (Stage 0)
Create a local-only, opt-in performance harness. It must be disabled by
default and absent from production behavior.
### Measurements
- Long tasks via `PerformanceObserver`.
- JS heap, where the browser exposes it.
- Active workers, timers, observers, URL media probes, torrent probes, and
relay subscription/request counts.
- Feed refresh duration, item count, and recomputation reason.
- Recommendation-index snapshots: input size, queue/coalescing count, worker
lifetime, and stale-to-fresh latency.
- Event-cache record count, estimated serialized bytes, restore duration, IDB
writes/deletes, and protected-versus-evicted record categories.
- Browser process CPU/RSS sampled externally during test scenarios.
### Standard scenarios
1. Cold load to Home; wait 60 seconds without interaction.
2. Scroll through two feed pages, then stop.
3. Open and close Profile; remain on Home.
4. Open Explore and For You; receive a new relevant note, follow, zap, and
moderation update.
5. Open hosted playback, then close it.
6. Open torrent-backed playback, then close it.
7. Background the tab for two minutes and restore it.
### Stage gate
Record baseline results before any optimization. The harness itself must have a
test proving it does not initialize unless its explicit local debug flag is
set.
## Stage 1 — Make localhost relay monitoring explicit
### Change
Change `js/devReqMonitor.js` so it only installs when
`localStorage.__bitvidReqMonitor__ === "1"` (or an equivalent explicit local
debug flag). Do not auto-enable merely because the host is localhost.
### Why
It patches every WebSocket send, parses REQ frames, and has a two-second timer.
It is useful during a relay-storm investigation but should not distort ordinary
local performance testing.
### Tests and acceptance
- With the flag off: no WebSocket patch and no monitoring timer.
- With the flag on: existing relay-storm warnings still identify REQ kinds.
- No production host can enable it without the explicit local flag.
- Compare idle local CPU/long-task baseline before and after.
### Rollback
One isolated commit; restoring the previous activation condition restores the
old diagnostic behavior.
## Stage 2 — Lazy recommendation indexes
### Change
Do not initialize `ExploreDataService` globally in application bootstrap.
Start it when Explore or For You is first opened, and stop/pause its intervals
when neither view is active or the document is hidden.
Keep Nostr ingestion active. Incoming videos, watch-history changes, follows,
zaps, preference changes, and relevant moderation changes mark the associated
index stale. Refresh the index immediately when its feed is visible, or just
before rendering it.
Use a small lifecycle coordinator with these states: `not-created`, `idle`,
`dirty`, `refreshing`, and `paused-hidden`. A refresh request while refreshing
sets one follow-up dirty bit rather than starting another full worker message.
Snapshot only the minimal fields required by the worker and record snapshot
size in the local-only harness.
### Why
The current worker receives cloned video/history data on a one-minute and
five-minute cadence even when the viewer is using Home, Profile, or a modal.
### Tests and acceptance
- Home/Profile-only session creates no Explore worker or recommendation
intervals.
- Opening Explore or For You initializes the worker once and produces the same
ranking inputs as today.
- A new relevant note, follow, watch event, zap, or moderation update is
reflected on the next active-feed refresh.
- Backgrounding the tab pauses the intervals; restoring it performs one bounded
catch-up refresh.
- Existing For You tiering, Trending, and recommendation tests remain green.
- Timer, visibility, and event triggers coalesce into at most one active and
one follow-up index calculation per index.
### Rollback
Feature-scoped lifecycle change behind a small coordinator boundary; restore
eager initialization if feed freshness regresses.
## Stage 3 — Bound source-health work to visible cards
### Change
Keep the existing viewport/preload observer and current two-at-a-time torrent
queue. Add an abort/cancellation token through URL probes and queue pruning for
WebTorrent work when cards leave the feed or its container is discarded. Hidden
cards that must be verified before reveal receive a small, observable per-feed
budget rather than an unbounded eager batch. Preserve deduplication by
event/source and separate positive/negative TTLs.
Do not probe every alternate mirror eagerly. Probe the primary source first and
only test an alternate when the primary is unavailable or playback needs it.
### Why
Media-element probes and WebTorrent tracker probes are useful for accurate
source badges and fallback, but are among the most expensive background tasks.
### Tests and acceptance
- Scrolling a large feed never exceeds the configured URL/torrent concurrency
limit.
- Off-screen and removed cards do not start or retain probes.
- Cached results render badges without a second request.
- Dead URL still falls back to a working mirror or WebTorrent source.
- Closing a feed/modal tears down pending health work.
- Hidden-card verification is bounded and still reveals cards whose source
proves healthy.
### Rollback
Keep the existing probe implementation available behind the observer boundary
until playback and badge tests pass on Chromium and Firefox.
## Stage 4 — Reduce playback visual overhead
### Change
First trace whether the video-modal ambient canvas is actually wired into the
current modal path, then profile it. If it appears in the hot path,
replace the free-running animation loop with `requestVideoFrameCallback` where
available, a capped fallback cadence, and automatic disablement for
`prefers-reduced-motion` or a low-power preference.
The effect must exist only while the modal video is actively playing and be
fully torn down on pause, close, source change, and hidden document.
### Tests and acceptance
- Hosted and torrent playback remain visually correct.
- Pause/close/source-change cancel all animation callbacks and observers.
- Background tabs make no canvas draws.
- Reduced-motion mode does not attach the visual effect.
- Compare renderer/GPU CPU while playing the same short clip before and after.
### Rollback
Keep the visual treatment behind a feature/config gate until manual playback QA
is complete.
## Stage 5 — Service-worker and image-cache cadence
### Change
- Keep service-worker registration, but avoid forcing a full update check on
every application boot. Use normal browser update cadence or a bounded
version/age policy.
- Add an image-cache freshness window so a cached external image is served
immediately and only revalidated when stale or needed by a visible card.
### Tests and acceptance
- A fresh deploy still activates normally without a hard refresh.
- Rollback deploy still works with hashed assets and normal reload.
- Cached thumbnails/avatars paint immediately.
- Warm feed loads do not revalidate every external image at once.
- Image cache remains bounded and recovers safely from quota failures.
### Rollback
Keep cache format/version semantics intact; any regression can revert the
freshness policy without deleting user-visible image caching.
## Stage 6 — Bundle and feature activation review
### Change
Start with the WebTorrent implementation, because it is currently imported by
the facade at module evaluation time. Review other heavy feature modules only
from measured cold-load transfer/parse evidence. Dynamically import at first
use where safe: profile management, DMs, upload, torrent playback, and
feature-specific Nostr helpers. Do not defer the active feed, authentication,
or basic playback path in a way that creates visible delay.
### Tests and acceptance
- Initial Home load has fewer transferred/parsed bytes.
- Opening each deferred UI initializes once and still works offline/static-hosted.
- No feature code is fetched when its flag is off.
- Build artifact, cache, E2E, and visual tests pass.
## Stage 7 — Cache budget and feed-refresh coalescing
### Change
After Stage 0 records real cache growth, add explicit event-cache record and
byte budgets plus category-aware eviction. Protect currently active feed items,
the newest active version of an event, local/user-owned content, recent watch
history pointers, and moderation state needed for the current viewer. Evict
older, unreferenced relay data first. In the feed coordinator, coalesce
same-feed refresh requests and record the initiating reason, without delaying
an explicit user refresh.
### Why
The cache is already incremental at write time, but its full-map snapshot and
restore behavior can become expensive as a long-lived relay session grows.
Coalescing protects against several valid live signals causing redundant
render/rank passes together.
### Tests and acceptance
- Seeded oversized caches restore only the protected/bounded record set and
issue normal scoped relay fetches for missing items.
- Feed data, deletions, replacements, moderation decisions, and recent watch
history produce the same visible result before and after eviction.
- A burst of equivalent refresh signals produces one running refresh plus, at
most, one follow-up; an explicit refresh is never swallowed.
- IndexedDB and localStorage fallback behavior remain covered.
### Rollback
Version cache metadata and keep the current restore format readable. Reverting
the budget must not erase cached user data.
## Release procedure
Each stage ships independently. The recommended order is Stage 0, Stage 1,
Stage 2, Stage 3, Stage 7, Stage 5, then the measurement-dependent visual and
bundle work (Stages 4 and 6):
1. Capture baseline and define the expected improvement.
2. Make one focused implementation commit.
3. Run the stage's targeted tests plus relevant browser QA.
4. Compare measurement results to the baseline.
5. Run `npm run build` and the applicable design checks.
6. Promote only when no feed freshness, playback, moderation, or cache
regression is observed.
Do not combine stages in one commit. A failed stage is reverted independently
without discarding validated improvements from earlier stages.
+1
View File
@@ -735,6 +735,7 @@ class Application {
ModerationActionController,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
userBlocks,
buildVideoAddressPointer,
VIDEO_EVENT_KIND,
+2
View File
@@ -19,6 +19,7 @@ export function createModerationCoordinator(deps) {
ModerationActionController,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
userBlocks,
buildVideoAddressPointer,
VIDEO_EVENT_KIND,
@@ -210,6 +211,7 @@ export function createModerationCoordinator(deps) {
services: {
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
userBlocks,
},
selectors: {
+6 -7
View File
@@ -1138,8 +1138,11 @@ export function createModerationStage({
const adminWhitelist = adminStatus?.whitelisted === true;
const adminWhitelistBypass = false;
const blockAutoplay =
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
// A trusted mute is a ranking signal, not a content verdict. It sinks an
// author in feed sorters, but a single stale or bad-faith mute must not
// blur a creator or stop playback. Only the report threshold, the
// viewer's own mute, or the higher mute-hide threshold can do that.
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold || viewerMuted;
const blurFromReports = trustedCount >= normalizedBlurThreshold;
let blurThumbnail = blurFromReports;
let blurReason = blurThumbnail ? "trusted-report" : "";
@@ -1269,22 +1272,18 @@ export function createModerationStage({
}
}
if (!blurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
if (!blurThumbnail && (viewerMuted || hideTriggered)) {
blurThumbnail = true;
if (hideTriggered) {
blurReason = hideReason || "trusted-hide";
} else if (viewerMuted) {
blurReason = "viewer-mute";
} else if (trustedMuted) {
blurReason = "trusted-mute";
}
} else if (blurThumbnail) {
if (hideTriggered) {
blurReason = hideReason || "trusted-hide";
} else if (viewerMuted && !blurFromReports) {
blurReason = "viewer-mute";
} else if (trustedMuted && !blurFromReports) {
blurReason = "trusted-mute";
} else if (!blurReason && blurFromReports) {
blurReason = "trusted-report";
}
+28
View File
@@ -17,6 +17,10 @@ export default class ModerationActionController {
typeof services.clearModerationOverride === "function"
? services.clearModerationOverride
: null,
clearAuthorModerationOverride:
typeof services.clearAuthorModerationOverride === "function"
? services.clearAuthorModerationOverride
: null,
userBlocks:
services.userBlocks && typeof services.userBlocks === "object"
? services.userBlocks
@@ -240,6 +244,10 @@ export default class ModerationActionController {
}
this.clearModerationOverride(video);
// The same Restore control is used after either a per-video override or
// an "Always show creator" override. Clear both scopes so the visible
// state can actually return to the default moderation decision.
this.clearAuthorModerationOverride(video);
const target = this.resolveTargetVideo(video);
this.clearViewerOverride(target);
@@ -503,6 +511,26 @@ export default class ModerationActionController {
}
}
clearAuthorModerationOverride(video) {
if (!this.services.clearAuthorModerationOverride) {
return;
}
const descriptor = this.getModerationOverrideDescriptor(video);
if (!descriptor?.authorPubkey) {
return;
}
try {
this.services.clearAuthorModerationOverride(descriptor.authorPubkey);
} catch (error) {
devLogger.warn(
"[ModerationActionController] Failed to clear author moderation override:",
error,
);
}
}
isUserLoggedIn() {
if (!this.auth.isLoggedIn) {
return false;
+10 -11
View File
@@ -300,20 +300,23 @@ export class ModerationDecorator {
: "";
const thresholds = this.getActiveModerationThresholds();
const computedBlockAutoplayBase =
trustedCount >= thresholds.autoplayBlockThreshold || trustedMuted;
// Trusted mutes are deliberately limited to feed ranking. A single muted
// list entry must not become a visible strike against a creator.
const computedBlockAutoplayBase = trustedCount >= thresholds.autoplayBlockThreshold;
const computedBlockAutoplay =
computedBlockAutoplayBase || viewerMuted || existingBlockAutoplay;
const blurFromReports = trustedCount >= thresholds.blurThreshold;
const preservesExistingBlur =
existingBlurThumbnail &&
existingBlurReason !== "trusted-mute" &&
existingBlurReason !== "trusted-mute-hide";
let computedBlurThumbnail =
blurFromReports || trustedMuted || viewerMuted || existingBlurThumbnail;
blurFromReports || viewerMuted || preservesExistingBlur;
let computedBlurReason = "";
if (blurFromReports) {
computedBlurReason = "trusted-report";
} else if (trustedMuted) {
computedBlurReason = "trusted-mute";
} else if (viewerMuted) {
computedBlurReason = "viewer-mute";
} else if (existingBlurThumbnail && existingBlurReason) {
@@ -352,22 +355,18 @@ export class ModerationDecorator {
hideTriggered = true;
}
if (!computedBlurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
if (!computedBlurThumbnail && (viewerMuted || hideTriggered)) {
computedBlurThumbnail = true;
if (hideTriggered) {
computedBlurReason = hideReason || "trusted-hide";
} else if (viewerMuted) {
computedBlurReason = "viewer-mute";
} else if (trustedMuted) {
computedBlurReason = "trusted-mute";
}
} else if (computedBlurThumbnail) {
if (hideTriggered) {
computedBlurReason = hideReason || "trusted-hide";
} else if (viewerMuted && !blurFromReports && !trustedMuted) {
} else if (viewerMuted && !blurFromReports) {
computedBlurReason = "viewer-mute";
} else if (trustedMuted && !blurFromReports) {
computedBlurReason = "trusted-mute";
} else if (!computedBlurReason && blurFromReports) {
computedBlurReason = "trusted-report";
}
-1
View File
@@ -160,7 +160,6 @@ export function normalizeVideoModerationContext(moderationInput) {
context.originalBlur ||
context.originalBlockAutoplay ||
context.trustedCount > 0 ||
context.trustedMuted ||
context.overrideActive ||
context.originalHidden ||
context.activeHidden ||
+36
View File
@@ -7,6 +7,11 @@ import {
createModerationAppHarness,
} from "../helpers/moderation-test-helpers.mjs";
import { userBlocks } from "../../js/userBlocks.js";
import {
clearAuthorModerationOverride,
hasAuthorModerationOverride,
setAuthorModerationOverride,
} from "../../js/state/cache.js";
const REPORTER_HEX = "b".repeat(64);
const MUTER_HEX = "c".repeat(64);
@@ -109,6 +114,37 @@ test("handleModerationOverride resumes deferred playback", async (t) => {
});
});
test("Restore default moderation clears both video and creator overrides", async (t) => {
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.moderationDecorator.updateSettings({
autoplayBlockThreshold: 1,
blurThreshold: 1,
});
const authorHex = "8".repeat(64);
const videoId = "e".repeat(64);
const video = {
id: videoId,
pubkey: authorHex,
moderation: buildModerationState(),
};
t.after(() => clearAuthorModerationOverride(authorHex));
setAuthorModerationOverride(authorHex, { showAnyway: true });
app.handleModerationOverride({ video });
assert.equal(hasAuthorModerationOverride(authorHex), true);
assert.equal(video.moderation.viewerOverride?.showAnyway, true);
const restored = app.handleModerationHide({ video });
assert.equal(restored, true);
assert.equal(hasAuthorModerationOverride(authorHex), false);
assert.equal(video.moderation.viewerOverride, undefined);
assert.equal(video.moderation.blockAutoplay, true);
assert.equal(video.moderation.blurThumbnail, true);
});
test("handleModerationBlock requests a block, clears overrides, and refreshes hidden state", async (t) => {
withMockedNostrTools(t);
@@ -6,6 +6,7 @@ import {
getDefaultModerationSettings,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
} from "../../js/state/cache.js";
import { userBlocks } from "../../js/userBlocks.js";
import { HEX64_REGEX } from "../../js/utils/hex.js";
@@ -453,6 +454,7 @@ export async function createModerationAppHarness(options = {}) {
userBlocks: app.userBlocks,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
},
auth: {
isLoggedIn: () => app.isUserLoggedIn(),
+7
View File
@@ -169,6 +169,13 @@ test("moderation stage annotates trusted mute metadata", async () => {
assert.equal(mutedItem.video.moderation.trustedMuteCount, 1);
assert.equal(mutedItem.metadata.moderation.trustedMuted, true);
assert.equal(mutedItem.metadata.moderation.trustedMuteCount, 1);
assert.equal(mutedItem.video.moderation.blockAutoplay, false);
assert.equal(mutedItem.video.moderation.blurThumbnail, false);
assert.equal(
mutedItem.video.moderation.blurReason,
undefined,
"a single trusted mute downranks without visibly flagging the creator",
);
const trustedMuteReason = reasons.find((entry) => entry.reason === "trusted-mute");
assert.ok(trustedMuteReason);
+10 -18
View File
@@ -108,6 +108,7 @@ test("VideoCard renders moderation badges and respects viewer override", async (
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.moderationDecorator.updateSettings({ trustedSpamHideThreshold: 3 });
const videoId = "a".repeat(64);
const video = {
@@ -204,6 +205,7 @@ test(
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.moderationDecorator.updateSettings({ trustedSpamHideThreshold: 3 });
const videoId = "b".repeat(64);
const video = {
@@ -336,7 +338,7 @@ test("VideoCard blurs viewer-muted creators", async (t) => {
assert.equal(card.moderationBadgeEl.dataset.moderationState, "blocked");
});
test("VideoCard blurs thumbnails when trusted mute triggers without reports", async (t) => {
test("VideoCard keeps a single trusted mute as a non-blocking ranking signal", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
@@ -380,26 +382,16 @@ test("VideoCard blurs thumbnails when trusted mute triggers without reports", as
document.body.appendChild(card.getRoot());
assert.equal(video.moderation.blurThumbnail, true);
assert.equal(video.moderation.blurReason, "trusted-mute");
assert.equal(video.moderation.blurThumbnail, false);
assert.equal(video.moderation.blurReason, "");
assert.equal(
normalizeVideoModerationContext(card.video?.moderation).activeBlur,
true,
false,
);
assert.equal(card.thumbnailEl.dataset.thumbnailState, "blurred");
assert.equal(card.authorPicEl.dataset.visualState, "blurred");
assert.equal(card.getRoot().dataset.autoplayPolicy, "blocked");
assert.equal(card.moderationBadgeEl.dataset.moderationState, "trusted-mute");
assert.equal(
card.moderationBadgeTextEl.textContent,
"Content or user blocked by a trusted contact.",
);
assert.ok(card.moderationBlockButton);
assert.equal(
card.moderationBlockButton?.dataset?.moderationAction,
"block",
);
assert.equal(card.moderationBlockButton.textContent, "Block");
assert.equal(card.thumbnailEl.dataset.thumbnailState, undefined);
assert.equal(card.authorPicEl.dataset.visualState, undefined);
assert.equal(card.getRoot().dataset.autoplayPolicy, undefined);
assert.equal(card.moderationBadgeEl, null);
});
test("VideoCard block action restores trusted mute hide state after override", async (t) => {