mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Improve moderation restoration and mobile card actions
This commit is contained in:
1 parent
3735d57410
commit
4525c1c657
14 files changed
+455
-44
No files matched your search
@@ -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%,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -735,6 +735,7 @@ class Application {
|
||||
ModerationActionController,
|
||||
setModerationOverride,
|
||||
clearModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
userBlocks,
|
||||
buildVideoAddressPointer,
|
||||
VIDEO_EVENT_KIND,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ export function normalizeVideoModerationContext(moderationInput) {
|
||||
context.originalBlur ||
|
||||
context.originalBlockAutoplay ||
|
||||
context.trustedCount > 0 ||
|
||||
context.trustedMuted ||
|
||||
context.overrideActive ||
|
||||
context.originalHidden ||
|
||||
context.activeHidden ||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user