diff --git a/components/profile-modal.html b/components/profile-modal.html
index 2a4249a5..00375790 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -1612,6 +1612,56 @@
data-orientation="vertical"
>
+
+
+
+ Trusted creators
+
+
+ Turn off web-of-trust warnings for a creator across all
+ their videos. They stay listed here until you reset them.
+
+
+
+
+ Creator npub
+
+
+
+ Always show
+
+
+
+
+ No trusted creators yet.
+
+
+
diff --git a/config/instance-config.js b/config/instance-config.js
index 1959fd2a..53f24d05 100644
--- a/config/instance-config.js
+++ b/config/instance-config.js
@@ -252,6 +252,18 @@ export const ALLOW_NSFW_CONTENT = false;
*/
export const FEATURE_NIP71_INGEST = true;
+/**
+ * Whether this deployment enables the Audio / Music / Podcast experience
+ * (dedicated Audio player, sidebar tab, and the channel / "My Content" Audio
+ * tabs — see docs/audio-integration-plan.md and TODO #60).
+ *
+ * Defaults to `false` — "off = no trace": with it off, nothing audio renders and
+ * audio-only notes stay filtered out of the video feeds (current behavior). Flip
+ * to `true` to light up the Audio surfaces. Watch/ingest-first; a separate
+ * publish flag will gate uploading audio later.
+ */
+export const FEATURE_AUDIO_INGEST = false;
+
/**
* Card liveness visibility policy — what a non-owner video card does while its
* CDN/WebTorrent liveness probes are still running:
diff --git a/docs/audio-integration-plan.md b/docs/audio-integration-plan.md
new file mode 100644
index 00000000..9d16c5a3
--- /dev/null
+++ b/docs/audio-integration-plan.md
@@ -0,0 +1,203 @@
+# Audio / Music / Podcasts — Dev Plan
+
+Status: **DECISIONS LOCKED (D1–D10, per recommendations, 2026-07-09)** — ready to
+build; config flag `FEATURE_AUDIO_INGEST` wired (default off). Sibling of the shorts
+(`docs/shorts-plan.md`) and live (`docs/live-ingest-plan.md` /
+`docs/live-publish-plan.md`) plans; shares the media-category model with both.
+Tracks TODO #60. The near-term "keep audio out of the video feed" guard is
+already shipped (see #60 / `convertEventToVideo` `isAudioOnlyVideoObject`).
+
+## Executive summary
+
+bitvid is video-only: it supports NIP-71 video (kinds 21/22/34235/34236) and
+native kind 30078, and has **no `
` player**. Podcasts/music published as
+native 30078 with `imeta m audio/*` currently render as a broken ``, so
+they are now filtered out of the video feeds. This plan turns "filter them out"
+into "give them a proper home": an **Audio** category with its own player,
+sidebar tab, channel-profile tab, and "My Content" tab — built on a **shared
+media-category resolver** (`video | audio | live | short`) used everywhere so a
+note lands in exactly one bucket. Watch/ingest-first (mirrors live-ingest),
+flag-gated from day one (off = no trace), whitelist-scoped like shorts/live.
+
+## Landscape — audio kinds & NIPs (surveyed 2026-07-09, local NIP mirror)
+
+| Surface | Kind / tag | Notes | Support |
+| --- | --- | --- | --- |
+| **Native re-upload** (today) | 30078 + `imeta m audio/*` | What Nostr Compass / Nodesignal use; bespoke, not a standard audio kind | pipeline exists |
+| **NIP-A0 Voice Messages** | `kind 1222` (root), `1244` (reply) | Native short audio notes; URL to audio file, rec. `audio/mp4`/.m4a, ≤60s | ❌ |
+| **NIP-73 External Content IDs** | `i`/`k` = `podcast:guid` / `podcast:item:guid` / `podcast:publisher:guid` | The Podcasting 2.0 / PodcastIndex / Fountain bridge — reference real feeds/episodes | ❌ |
+| **NIP-53 live audio** | `kind 30311` (stream), `30312` (audio rooms/spaces) | Live audio; **owned by #16**, not this plan | ❌ (#16) |
+| **NIP-71 audio variants** | `imeta m audio/*` + `waveform` | Already in the spec bitvid parses (audio track on a video) | partial |
+| **Music apps** (Wavlake, Stemstr) | app-specific custom kinds | Not ratified NIPs → opt-in integration only, not core | ❌ |
+
+## Decisions needed
+
+> **DECISION 1 — v1 source scope. ✅ LOCKED (= recommendation below).**
+> Which sources count as "audio" for the first release?
+> - **Option A — Native only:** kind 30078 with `imeta m audio/*` (+ a native
+> `t:audio` marker for new bitvid publishing). Lowest effort — reuses the whole
+> existing 30078 pipeline; matches the data already in the wild.
+> - **Option B — A + NIP-A0 voice notes** (`kind 1222`/`1244`). Adds a small,
+> standardized native audio kind. Moderate.
+> - **Option C — A + B + NIP-73 podcast-feed ingest** (pull real podcast RSS via
+> PodcastIndex GUIDs). Full podcast client. Biggest — its own phase.
+> _Recommendation: **A for v1**, B fast-follow, C as a later phase (D7)._
+
+> **DECISION 2 — Discovery / whitelist scope. ✅ LOCKED (= recommendation below).**
+> Mirror shorts D1 / live D1: list audio only from **whitelisted/trusted authors**?
+> _Recommendation: **Yes** — same scope as video/shorts/live, one consistent trust
+> model. (No separate audio allowlist.)_
+
+> **DECISION 3 — Does audio appear in the main feed, or Audio-tab only? ✅ LOCKED (= recommendation below).**
+> - **Option A — Audio-tab only** (excluded from main/discovery video feeds).
+> Consistent with the shipped guard that already excludes audio from video feeds,
+> and with shorts D3 (Shorts-tab only).
+> - **Option B — also mixed into the main feed** with an audio card treatment.
+> _Recommendation: **Option A** — keeps the video feed clean; audio is a
+> deliberate destination, not an interruption._
+
+> **DECISION 4 — Player model. ✅ LOCKED: A for v1, architected toward B.**
+> - **Option A — Reuse the player modal in "audio mode":** detect audio → swap the
+> `` for an `` element + big poster art, reuse the existing
+> zaps/comments/moderation/share chrome. Fastest; one modal.
+> - **Option B — Persistent mini audio player** (bottom bar that keeps playing while
+> you browse — the podcast/music norm). Best listening UX; larger build (global
+> playback state, survives navigation).
+> _Recommendation: **A for v1**, **B as the real target** — audio's whole value is
+> "keep listening while you browse," so plan the architecture so A can grow into B._
+
+> **DECISION 5 — Category signal / resolver. ✅ LOCKED (= recommendation below).**
+> How is a note bucketed as audio (must be deterministic + shared)?
+> Signals, in priority: (1) `imeta m audio/*` with no video variant, (2) kind
+> `1222`/`1244` (if D1≥B), (3) `podcast:*` `i` tag (if D1=C), (4) native `t:audio`
+> marker, (5) unambiguous audio URL extension (the shipped fallback). `.ogg` stays
+> video unless imeta says audio.
+> _Also decide: **adopt a `t:audio` marker** (parallel to `t:video`) for bitvid's
+> own audio publishing so the Audio feed has a clean relay-side `#t` filter?
+> Recommendation: **Yes.**_
+
+> **DECISION 6 — Publish vs watch-only for v1. ✅ LOCKED (= recommendation below).**
+> - **Option A — Watch/ingest-only** (mirrors live-ingest): bitvid surfaces others'
+> audio, no upload UI yet.
+> - **Option B — Publish too:** upload an audio file → native 30078 with
+> `imeta m audio/*` + `t:audio` (and/or NIP-A0 `1222` for short voice notes).
+> _Recommendation: **A for v1**; split publish into its own plan/phase like
+> live-ingest vs live-publish._
+
+> **DECISION 7 — Podcast-feed interop (NIP-73). ✅ LOCKED: Yes, Phase 3.**
+> Should bitvid reference real Podcasting 2.0 feeds/episodes (link an audio note to
+> its `podcast:guid` / `podcast:item:guid`, dedupe re-uploads, interop with Fountain
+> / PodcastIndex)? _Recommendation: **Yes, but Phase 3** — high interop value, but
+> pulls in RSS/GUID resolution; not v1._
+
+> **DECISION 8 — One "Audio" category, or split Music vs Podcasts? ✅ LOCKED (= recommendation below).**
+> _Recommendation: **one Audio category** for v1 (podcasts + music together); add a
+> Music sub-filter later if Wavlake-style content appears. Splitting now needs a
+> reliable music-vs-podcast signal we don't have._
+
+> **DECISION 9 — Value-for-Value / zaps. ✅ LOCKED (= recommendation below).**
+> Podcasting 2.0 has streaming V4V (per-minute sats). bitvid has one-shot zaps.
+> _Recommendation: **reuse the existing zap system** for v1; per-minute streaming
+> V4V is a future enhancement, not v1._
+
+> **DECISION 10 — Config flag. ✅ LOCKED (= recommendation below).**
+> Name + granularity. _Recommendation: `FEATURE_AUDIO_INGEST` (off = no trace),
+> later `FEATURE_AUDIO_PUBLISH`; matches `FEATURE_LIVE_INGEST` / shorts pattern._
+
+## Config flags (off = no trace)
+
+- `FEATURE_AUDIO_INGEST` (default **false**) — gates the Audio sidebar tab, the
+ channel/`My Content` Audio tabs, the audio player, and audio-category resolution.
+ With it off, nothing audio renders and the shipped guard keeps audio out of the
+ video feeds (current behavior).
+- `FEATURE_AUDIO_PUBLISH` (default **false**, later) — gates the upload-side audio
+ content-type + native marker.
+
+## Field mapping — detecting an "audio" note (the shared resolver)
+
+Add ONE `resolveMediaCategory(video) → "video" | "audio" | "live" | "short"` used
+by the sidebar feeds, the public channel tabs, AND the profile-modal "My Content"
+tabs (TODO #60). Audio branch reuses the already-shipped
+`isAudioOnlyVideoObject(video)` logic:
+
+- **audio** ⇐ no magnet AND no `imeta m video/*` AND ( `imeta m audio/*` OR audio
+ kind `1222`/`1244` OR a `podcast:*` `i` tag OR `t:audio` OR an unambiguous audio
+ URL `.mp3/.m4a/.m4b/.f4a/.aac/.wav/.flac/.opus/.oga/.weba/.mka` ). `.ogg` alone
+ stays video.
+- **short**/**live** per shorts-plan / live-ingest-plan.
+- **video** otherwise (default).
+
+Metadata an audio card wants (already present on the wild podcast notes):
+`title`, `image`/`thumbnail` (cover art), `duration`, `summary`/`description`,
+`channel`, `show`, podcast `t` tags. NIP-71 audio variants also carry `waveform`.
+
+## Architecture
+
+- **Category resolver** (`js/nostr/…` or `js/services/…`) — single source of truth
+ (D5). Feed engine + channelProfile + MyVideosController all call it. This is the
+ natural home for the #17 unified media grid.
+- **Per-category lazy loaders** — each tab (sidebar + channel + My Content) fetches
+ only its category on select; no cross-contamination (audio guard already keeps
+ audio out of video and vice-versa).
+- **Audio player** (D4) — v1: player modal detects `category==="audio"` → renders
+ `` + poster + existing zap/comment/moderation chrome. Architect the
+ playback/session layer so it can later hoist into a **persistent mini-player**
+ (Option B) without rewriting the surfaces.
+- **Channel profile + "My Content"** — TODO #60 tab strips (Videos / Audio / Live /
+ Shorts), empty categories hidden; "My Videos" pane renamed **My Content**.
+
+## Audio / podcast UX best practices
+
+- **Keep playing while browsing** — the defining audio behavior; the persistent
+ mini-player (D4-B) is what makes bitvid usable as a podcast/music app.
+- **Cover art forward** — audio cards lead with `image`; no 16:9 letterboxing.
+- **Duration + resume** — show length; remember playback position per episode
+ (ties into watch-history).
+- **Speed + skip** — 1x–2x rate, ±15/30s skip; background/lock-screen media session
+ (`navigator.mediaSession`) so phones show cover art + transport controls.
+- **Waveform** (nice-to-have) when NIP-71 `waveform` is present.
+
+## Phases (each flag-gated from day one)
+
+- **Phase 0 — Resolver + guard alignment.** Ship `resolveMediaCategory`; refactor
+ the shipped `isAudioOnlyVideoObject` to feed it. No UX. (Unblocks shorts/live too.)
+- **Phase 1 — Audio tab + player (watch-only, D1=A).** Sidebar Audio tab (whitelist-
+ scoped), audio-mode player modal, native 30078 `m audio/*` + `t:audio`. The
+ headline.
+- **Phase 1.5 — Channel + My Content tabs** (TODO #60). Category tab strips on the
+ public channel and the renamed "My Content" pane.
+- **Phase 2 — NIP-A0 voice notes** (D1=B). Ingest `1222`/`1244`; short-note card.
+- **Phase 2.5 — Publish** (`FEATURE_AUDIO_PUBLISH`, D6-B). Upload audio → native
+ 30078 `m audio/*` + `t:audio`; optional NIP-A0 for short voice.
+- **Phase 3 — Persistent mini-player** (D4-B) + **NIP-73 podcast-feed interop** (D7):
+ reference real feeds/episodes, dedupe re-uploads, PodcastIndex/Fountain interop.
+
+## Moderation, whitelist & NSFW
+
+Inherit the existing model unchanged (same as shorts/live): whitelist scope (D2),
+web-of-trust mute/report blur, per-event blacklist, NSFW gate — all key off the
+author + event, not the media type. The account-level WoT override (#24) applies
+to audio creators too.
+
+## Risks / watch-items
+
+- **Broken/foreign audio formats** — some `audio/*` won't play cross-browser; the
+ `` element degrades better than `` but still probe/fallback.
+- **Re-upload duplication** — the same episode as a native 30078 AND (later) a
+ NIP-73-referenced feed item → dedupe by `podcast:item:guid` once D7 lands.
+- **Persistent player scope creep** — global playback state touches routing/history;
+ keep v1 to the modal, but don't design it in a way that blocks D4-B.
+- **Empty tabs** — whitelist-scoped audio may be sparse; hide empty categories.
+- **Music licensing** — surfacing Wavlake-style music is an integration, not core;
+ keep it opt-in (D8).
+
+## Cross-refs & sources
+
+- TODO #60 (audio tab, channel/My Content tabs, resolver), #16 (live audio),
+ #17 (unified media grid), #24 (WoT override).
+- Sibling plans: `docs/shorts-plan.md`, `docs/live-ingest-plan.md`,
+ `docs/live-publish-plan.md`, `docs/nip71-migration-plan.md`.
+- Specs (local mirror `docs/nips/`): NIP-A0 (Voice Messages 1222/1244), NIP-73
+ (External Content IDs / podcast GUIDs), NIP-53 (Live 30311/30312), NIP-71
+ (video + audio variants), NIP-94/96 + Blossom (blob hosting — where the audio
+ files live, e.g. `relay.towardsliberty.com`).
diff --git a/js/app.js b/js/app.js
index 18ac9cf6..3e840d46 100644
--- a/js/app.js
+++ b/js/app.js
@@ -177,6 +177,10 @@ import {
setModerationOverride,
clearModerationOverride,
loadModerationOverridesFromStorage,
+ getAuthorModerationOverridesList,
+ setAuthorModerationOverride,
+ clearAuthorModerationOverride,
+ loadAuthorModerationOverridesFromStorage,
getModerationSettings,
getDefaultModerationSettings,
setModerationSettings,
@@ -918,6 +922,7 @@ class Application {
}
loadModerationOverridesFromStorage();
+ loadAuthorModerationOverridesFromStorage();
loadModerationSettingsFromStorage();
loadDmPrivacySettingsFromStorage();
this.moderationSettings = this.normalizeModerationSettings(
diff --git a/js/config.js b/js/config.js
index 5f833253..35a8e236 100644
--- a/js/config.js
+++ b/js/config.js
@@ -25,6 +25,7 @@ import {
DEFAULT_WHITELIST_MODE_ENABLED,
ALLOW_NSFW_CONTENT,
FEATURE_NIP71_INGEST,
+ FEATURE_AUDIO_INGEST,
CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN,
DEFAULT_TRUST_SEED_NPUBS,
@@ -121,6 +122,7 @@ export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources";
export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
export { ALLOW_NSFW_CONTENT };
export { FEATURE_NIP71_INGEST };
+export { FEATURE_AUDIO_INGEST };
export { CARD_LIVENESS_POLICY };
export { LIVENESS_PROBE_PREFETCH_MARGIN };
export { DEFAULT_TRUST_SEED_NPUBS };
diff --git a/js/constants.js b/js/constants.js
index 432cba40..08c9d14e 100644
--- a/js/constants.js
+++ b/js/constants.js
@@ -7,6 +7,7 @@ import {
DEFAULT_PLAYBACK_SOURCE,
DEFAULT_PLAYBACK_START_TIMEOUT,
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
+ FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN as CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN,
IS_DEV_MODE,
@@ -194,6 +195,10 @@ const DEFAULT_FLAGS = Object.freeze({
// Inbound ingest of NIP-71 videos published by other Nostr apps. Sourced from
// the admin instance config so a deployer flips it in one place.
FEATURE_NIP71_INGEST: CONFIG_FEATURE_NIP71_INGEST !== false,
+ // Audio / Music / Podcast surfaces (docs/audio-integration-plan.md, TODO #60).
+ // Default OFF ("off = no trace") — only an explicit `true` in the instance
+ // config lights it up.
+ FEATURE_AUDIO_INGEST: CONFIG_FEATURE_AUDIO_INGEST === true,
FEATURE_HASHTAG_PREFERENCES: false,
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
FEATURE_TRUSTED_HIDE_CONTROLS: true,
@@ -220,6 +225,7 @@ const runtimeFlags = (() => {
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR,
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
+ FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
@@ -265,6 +271,11 @@ export let FEATURE_NIP71_INGEST = coerceBoolean(
DEFAULT_FLAGS.FEATURE_NIP71_INGEST
);
+export let FEATURE_AUDIO_INGEST = coerceBoolean(
+ runtimeFlags.FEATURE_AUDIO_INGEST,
+ DEFAULT_FLAGS.FEATURE_AUDIO_INGEST
+);
+
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
diff --git a/js/nostr/client.js b/js/nostr/client.js
index e48f4b92..05572104 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -25,6 +25,7 @@ import {
buildNip71MetadataTags,
collectNip71PointerRequests,
convertEventToVideo,
+ isAudioOnlyVideoObject,
extractNip71MetadataFromTags,
getDTagValueFromTags,
mergeNip71MetadataIntoVideo as mergeNip71MetadataIntoVideoHelper,
@@ -4469,9 +4470,13 @@ export class NostrClient {
}
getActiveVideos() {
- return Array.from(this.activeMap.values()).sort(
- (a, b) => b.created_at - a.created_at
- );
+ // Filter audio-only notes (podcasts/music) here too, not just at ingest:
+ // video objects rehydrated from the persisted cache bypass
+ // convertEventToVideo, so a note cached before the audio guard existed would
+ // otherwise still reach the feed. See TODO #60.
+ return Array.from(this.activeMap.values())
+ .filter((video) => !isAudioOnlyVideoObject(video))
+ .sort((a, b) => b.created_at - a.created_at);
}
// Optimistically add a just-published video event to the local caches so it
diff --git a/js/nostr/index.js b/js/nostr/index.js
index a2c6714d..90f2da15 100644
--- a/js/nostr/index.js
+++ b/js/nostr/index.js
@@ -18,6 +18,7 @@ export {
buildNip71VideoEvent,
collectNip71PointerRequests,
convertEventToVideo,
+ isAudioOnlyVideoObject,
extractNip71MetadataFromTags,
getDTagValueFromTags,
mergeNip71MetadataIntoVideo,
diff --git a/js/nostr/managers/PersistenceManager.js b/js/nostr/managers/PersistenceManager.js
index 9f96c759..16b0a9fa 100644
--- a/js/nostr/managers/PersistenceManager.js
+++ b/js/nostr/managers/PersistenceManager.js
@@ -1,4 +1,5 @@
import { EventsCacheStore } from "./EventsCacheStore.js";
+import { isAudioOnlyVideoObject } from "../nip71.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
@@ -154,6 +155,12 @@ export class PersistenceManager {
continue;
}
+ // Drop audio-only notes (podcasts/music) cached before the audio guard
+ // existed so they don't get rehydrated into the feed. See TODO #60.
+ if (isAudioOnlyVideoObject(video)) {
+ continue;
+ }
+
this.client.applyRootCreatedAt(video);
const activeKey = this.client.getActiveKey(video);
diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js
index 44d74579..4510f77d 100644
--- a/js/nostr/nip71.js
+++ b/js/nostr/nip71.js
@@ -1461,6 +1461,70 @@ function inferMimeTypeFromUrl(url) {
* @param {import("nostr-tools").Event} event - The raw event.
* @returns {Object} The normalized video object, or { invalid: true, reason: string }.
*/
+// Inspect an event's `imeta` tags for declared media types. Native bitvid notes
+// (kind 30078) can carry `imeta` variants with an `m ` field just like
+// NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the
+// video/audio split in nip71IngestAdapter.js so the native path stays consistent.
+function detectImetaMediaKinds(tags = []) {
+ let hasVideo = false;
+ let hasAudio = false;
+ for (const tag of Array.isArray(tags) ? tags : []) {
+ if (!Array.isArray(tag) || tag[0] !== "imeta") {
+ continue;
+ }
+ for (let i = 1; i < tag.length; i += 1) {
+ const entry = tag[i];
+ if (typeof entry !== "string") {
+ continue;
+ }
+ const trimmed = entry.trim();
+ if (!/^m\s+/i.test(trimmed)) {
+ continue;
+ }
+ const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
+ if (mime.startsWith("video/")) {
+ hasVideo = true;
+ } else if (mime.startsWith("audio/")) {
+ hasAudio = true;
+ }
+ }
+ }
+ return { hasVideo, hasAudio };
+}
+
+// Unambiguous audio-only file extensions (the fallback when an event carries no
+// `imeta m audio/*` hint). `.ogg` is deliberately excluded — it can be video
+// (Theora) and bitvid maps it to video/ogg — so `.ogg` is only treated as audio
+// when an `imeta m audio/*` tag says so. `.f4a`/`.m4b` are AAC-in-MP4 audio
+// (podcasts/audiobooks) — NOT `.mp4`/`.m4v`, which are video.
+const AUDIO_ONLY_URL_PATTERN =
+ /\.(mp3|m4a|m4b|f4a|aac|wav|flac|opus|oga|weba|mka)(?:[?#]|$)/i;
+
+// Whether a (converted) video object is an audio-only note — a podcast/music
+// note published as a native kind-30078. bitvid has no player, so these
+// render as a broken and must be kept out of the video feeds. Operates
+// on a video object (has `url`, `magnet`, `tags`) so it works on BOTH freshly
+// converted events AND video objects rehydrated from the persisted cache (which
+// bypass convertEventToVideo). Audio-only = no magnet (a torrent could be video)
+// AND no `imeta m video/*` variant AND (an `imeta m audio/*` variant OR an
+// unambiguous audio URL). See TODO #60.
+export function isAudioOnlyVideoObject(video) {
+ if (!video || typeof video !== "object") {
+ return false;
+ }
+ const url = typeof video.url === "string" ? video.url.trim() : "";
+ const magnet = typeof video.magnet === "string" ? video.magnet.trim() : "";
+ if (magnet) {
+ return false;
+ }
+ const { hasVideo, hasAudio } = detectImetaMediaKinds(video.tags);
+ if (hasVideo) {
+ return false;
+ }
+ const urlLooksAudioOnly = url ? AUDIO_ONLY_URL_PATTERN.test(url) : false;
+ return hasAudio || urlLooksAudioOnly;
+}
+
export function convertEventToVideo(event = {}) {
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
@@ -1507,6 +1571,13 @@ export function convertEventToVideo(event = {}) {
return { id: event.id, invalid: true, reason: "missing playable source" };
}
+ // Keep audio-only notes (podcasts/music published as native video events) out
+ // of the video feed: bitvid has no player, so they render as a broken
+ // . See TODO #60 for the future dedicated Audio tab.
+ if (isAudioOnlyVideoObject({ url, magnet, tags })) {
+ return { id: event.id, invalid: true, reason: "audio-only source (no video)" };
+ }
+
const thumbnail = safeTrim(parsedContent.thumbnail);
const description = safeTrim(parsedContent.description);
const rawMode = safeTrim(parsedContent.mode);
diff --git a/js/services/moderationDecorator.js b/js/services/moderationDecorator.js
index 2236462f..5288b22b 100644
--- a/js/services/moderationDecorator.js
+++ b/js/services/moderationDecorator.js
@@ -2,7 +2,7 @@
import { getTrustedMuteHideThreshold, getTrustedSpamHideThreshold, DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD } from "../constants.js";
import { formatShortNpub } from "../utils/formatters.js";
-import { getModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js";
+import { getModerationOverride, getAuthorModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js";
import { userLogger } from "../utils/logger.js";
/**
@@ -403,14 +403,22 @@ export class ModerationDecorator {
const computedHidden = hideTriggered && !hideBypass;
+ const authorPubkey = video.pubkey || video.author?.pubkey || "";
const overrideEntry = getModerationOverride({
eventId: video.id,
- authorPubkey: video.pubkey || video.author?.pubkey || "",
+ authorPubkey,
});
- const overrideActive = overrideEntry?.showAnyway === true;
+ // Account-level ("trusted creator") override: suppresses the WoT warning for
+ // ALL of this author's videos, exactly like a per-video "show anyway".
+ const authorOverrideEntry = getAuthorModerationOverride(authorPubkey);
+ const overrideActive =
+ overrideEntry?.showAnyway === true ||
+ authorOverrideEntry?.showAnyway === true;
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
? Math.floor(overrideEntry.updatedAt)
- : Date.now();
+ : Number.isFinite(authorOverrideEntry?.updatedAt)
+ ? Math.floor(authorOverrideEntry.updatedAt)
+ : Date.now();
const originalHideCounts = hideCounts
? {
diff --git a/js/services/nostrService.js b/js/services/nostrService.js
index fe226e38..0fa15f28 100644
--- a/js/services/nostrService.js
+++ b/js/services/nostrService.js
@@ -1,5 +1,5 @@
import { nostrClient } from "../nostrClientFacade.js";
-import { convertEventToVideo } from "../nostr/index.js";
+import { convertEventToVideo, isAudioOnlyVideoObject } from "../nostr/index.js";
import { accessControl } from "../accessControl.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
import { devLogger, userLogger } from "../utils/logger.js";
@@ -1502,6 +1502,13 @@ export class NostrService {
return false;
}
+ // Audio-only notes (podcasts/music) have no to play — keep them out
+ // of every video grid, even the viewer's own channel. Catches objects
+ // rehydrated from a pre-guard cache that skipped convertEventToVideo (#60).
+ if (isAudioOnlyVideoObject(video)) {
+ return false;
+ }
+
const viewerIsAuthor = this.isViewerVideoAuthor(video);
if (viewerIsAuthor) {
diff --git a/js/state/cache.js b/js/state/cache.js
index 2fca6225..9bbebca4 100644
--- a/js/state/cache.js
+++ b/js/state/cache.js
@@ -33,6 +33,12 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2";
const MODERATION_OVERRIDE_STORAGE_VERSION = 2;
+// Account-level ("trusted creator") web-of-trust overrides, keyed by author hex.
+// A showAnyway entry suppresses the WoT blur/hide/warning for ALL of that
+// author's videos until the viewer resets it in the Safety tab.
+const AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY =
+ "bitvid:moderationAuthorOverrides:v1";
+const AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION = 1;
const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1";
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1";
@@ -198,6 +204,7 @@ const profileCache = new Map();
const urlHealthCache = new Map();
const urlHealthInFlight = new Map();
const moderationOverrides = new Map();
+const authorModerationOverrides = new Map();
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
if (previousProfiles === nextProfiles) {
@@ -1720,6 +1727,169 @@ export function clearModerationOverride(
return removed;
}
+// ---- Account-level ("trusted creator") web-of-trust overrides ---------------
+// Keyed by author hex; a showAnyway entry suppresses the WoT blur/hide/warning
+// for EVERY video by that author until reset. Mirrors the per-video store above.
+
+function normalizeAuthorOverrideHex(pubkey) {
+ const hex = normalizeHexPubkey(pubkey);
+ return hex ? hex.toLowerCase() : "";
+}
+
+export function getAuthorModerationOverridesList() {
+ return Array.from(authorModerationOverrides.values()).map((entry) => ({
+ ...entry,
+ }));
+}
+
+export function getAuthorModerationOverride(pubkey) {
+ const hex = normalizeAuthorOverrideHex(pubkey);
+ if (!hex) {
+ return null;
+ }
+ const entry = authorModerationOverrides.get(hex);
+ return entry ? { ...entry } : null;
+}
+
+export function hasAuthorModerationOverride(pubkey) {
+ const hex = normalizeAuthorOverrideHex(pubkey);
+ if (!hex) {
+ return false;
+ }
+ return authorModerationOverrides.get(hex)?.showAnyway === true;
+}
+
+export function setAuthorModerationOverride(
+ pubkey,
+ override = {},
+ { persist = true } = {},
+) {
+ const hex = normalizeAuthorOverrideHex(pubkey);
+ if (!hex) {
+ return null;
+ }
+
+ const showAnyway = override?.showAnyway === true;
+ if (!showAnyway) {
+ const removed = authorModerationOverrides.delete(hex);
+ if (removed && persist) {
+ persistAuthorModerationOverridesToStorage();
+ }
+ return null;
+ }
+
+ const updatedAt = Number.isFinite(override?.updatedAt)
+ ? Math.floor(override.updatedAt)
+ : Date.now();
+ const existing = authorModerationOverrides.get(hex);
+ const nextEntry = { authorPubkey: hex, showAnyway: true, updatedAt };
+ const changed =
+ !existing ||
+ existing.showAnyway !== true ||
+ existing.updatedAt !== updatedAt;
+
+ authorModerationOverrides.set(hex, nextEntry);
+ if (persist && changed) {
+ persistAuthorModerationOverridesToStorage();
+ }
+ return { ...nextEntry };
+}
+
+export function clearAuthorModerationOverride(pubkey, { persist = true } = {}) {
+ const hex = normalizeAuthorOverrideHex(pubkey);
+ if (!hex) {
+ return false;
+ }
+ const removed = authorModerationOverrides.delete(hex);
+ if (removed && persist) {
+ persistAuthorModerationOverridesToStorage();
+ }
+ return removed;
+}
+
+export function loadAuthorModerationOverridesFromStorage() {
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+ const raw = localStorage.getItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY);
+ if (!raw) {
+ return;
+ }
+ try {
+ const payload = JSON.parse(raw);
+ if (
+ !payload ||
+ typeof payload !== "object" ||
+ payload.version !== AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION ||
+ !Array.isArray(payload.entries)
+ ) {
+ return;
+ }
+ authorModerationOverrides.clear();
+ for (const entry of payload.entries) {
+ if (!entry || entry.showAnyway !== true) {
+ continue;
+ }
+ const hex = normalizeAuthorOverrideHex(entry.authorPubkey || entry.pubkey);
+ if (!hex) {
+ continue;
+ }
+ const updatedAt = Number.isFinite(entry.updatedAt)
+ ? Math.floor(entry.updatedAt)
+ : Date.now();
+ authorModerationOverrides.set(hex, {
+ authorPubkey: hex,
+ showAnyway: true,
+ updatedAt,
+ });
+ }
+ } catch (error) {
+ authorModerationOverrides.clear();
+ userLogger.warn(
+ "[cache.loadAuthorModerationOverridesFromStorage] Failed to parse payload:",
+ error,
+ );
+ }
+}
+
+export function persistAuthorModerationOverridesToStorage() {
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+ const entries = [];
+ for (const entry of authorModerationOverrides.values()) {
+ if (!entry || entry.showAnyway !== true) {
+ continue;
+ }
+ entries.push({
+ authorPubkey: entry.authorPubkey,
+ showAnyway: true,
+ updatedAt: Number.isFinite(entry.updatedAt)
+ ? Math.floor(entry.updatedAt)
+ : Date.now(),
+ });
+ }
+ try {
+ if (entries.length === 0) {
+ localStorage.removeItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY);
+ return;
+ }
+ localStorage.setItem(
+ AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY,
+ JSON.stringify({
+ version: AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION,
+ savedAt: Date.now(),
+ entries,
+ }),
+ );
+ } catch (error) {
+ userLogger.warn(
+ "[cache.persistAuthorModerationOverridesToStorage] Failed to persist overrides:",
+ error,
+ );
+ }
+}
+
function buildUrlProbeKey(url, options = {}) {
const trimmed = typeof url === "string" ? url : "";
const mode = options?.confirmPlayable ? "playable" : "basic";
diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js
index 1cbec0ab..ebd96633 100644
--- a/js/ui/applicationBootstrap.js
+++ b/js/ui/applicationBootstrap.js
@@ -58,6 +58,9 @@ import {
getDmPrivacySettings,
setDmPrivacySettings,
clearModerationOverride,
+ getAuthorModerationOverridesList,
+ setAuthorModerationOverride,
+ clearAuthorModerationOverride,
persistSavedProfiles,
getSavedProfiles,
getActiveProfilePubkey,
@@ -93,6 +96,30 @@ import {
} from "./components/staticModalAccessibility.js";
import ModalManager from "./ModalManager.js";
+// After an account-level ("trusted creator") override is set/cleared, refresh
+// the feed so every video by that author re-decorates (warning on/off), and
+// fire the shared moderation-override event so the Safety tab list refreshes.
+function notifyAuthorModerationOverrideChanged(app, pubkey) {
+ try {
+ if (typeof document !== "undefined") {
+ document.dispatchEvent(
+ new CustomEvent("video:moderation-override", {
+ detail: { authorPubkey: pubkey || "", accountLevel: true },
+ }),
+ );
+ }
+ } catch (error) {
+ // best-effort
+ }
+ try {
+ if (typeof app?.onVideosShouldRefresh === "function") {
+ void app.onVideosShouldRefresh({ reason: "author-moderation-override" });
+ }
+ } catch (error) {
+ // best-effort
+ }
+}
+
export default class ApplicationBootstrap {
constructor({
app,
@@ -550,6 +577,22 @@ export default class ApplicationBootstrap {
getModerationOverrides: () => getModerationOverridesList(),
clearModerationOverride: (descriptor) =>
clearModerationOverride(descriptor),
+ // Account-level ("trusted creator") WoT overrides. Setting/clearing
+ // one re-renders the feed so every video by that author updates, and
+ // fires the shared event so the Safety tab list refreshes.
+ getAuthorModerationOverrides: () => getAuthorModerationOverridesList(),
+ setAuthorModerationOverride: (pubkey) => {
+ const entry = setAuthorModerationOverride(pubkey, {
+ showAnyway: true,
+ });
+ notifyAuthorModerationOverrideChanged(app, pubkey);
+ return entry;
+ },
+ clearAuthorModerationOverride: (pubkey) => {
+ const removed = clearAuthorModerationOverride(pubkey);
+ notifyAuthorModerationOverrideChanged(app, pubkey);
+ return removed;
+ },
loadVideos: (forceFetch, context) =>
app.loadVideos(forceFetch, context),
onVideosShouldRefresh: (context) =>
@@ -1079,6 +1122,24 @@ export default class ApplicationBootstrap {
),
);
}
+ // #24: "Always show this creator" overlay button → set the account-level
+ // web-of-trust override for that author + refresh the feed. Fired by the
+ // moderation badge on video cards / the player / similar cards.
+ if (typeof document !== "undefined") {
+ const onTrustAuthor = (event) => {
+ const pubkey =
+ typeof event?.detail?.pubkey === "string" ? event.detail.pubkey : "";
+ if (!pubkey) {
+ return;
+ }
+ setAuthorModerationOverride(pubkey, { showAnyway: true });
+ notifyAuthorModerationOverrideChanged(app, pubkey);
+ };
+ document.addEventListener("video:trust-author", onTrustAuthor);
+ nostrUnsubscribes.push(() =>
+ document.removeEventListener("video:trust-author", onTrustAuthor),
+ );
+ }
const submissionsPollId = setInterval(() => {
void app.refreshSubmissionsIndicator({ reason: "poll" });
}, 180000);
diff --git a/js/ui/components/SimilarContentCard.js b/js/ui/components/SimilarContentCard.js
index 93705b69..8f35a33f 100644
--- a/js/ui/components/SimilarContentCard.js
+++ b/js/ui/components/SimilarContentCard.js
@@ -134,6 +134,8 @@ export class SimilarContentCard {
this.handleModerationHideClick(event);
this.boundModerationBlockHandler = (event) =>
this.handleModerationBlockClick(event);
+ this.boundTrustAuthorHandler = (event) =>
+ this.handleTrustAuthorClick(event);
this.build();
}
@@ -524,6 +526,43 @@ export class SimilarContentCard {
return button;
}
+ createModerationTrustAuthorButton() {
+ const button = this.document.createElement("button");
+ button.classList.add(
+ "moderation-badge__action",
+ "flex-shrink-0",
+ "text-xs",
+ "py-1",
+ "px-2"
+ );
+ button.type = "button";
+ button.dataset.moderationAction = "trust-author";
+ button.setAttribute("aria-label", "Always show videos from this creator");
+ button.textContent = "Always show creator";
+ button.addEventListener("click", this.boundTrustAuthorHandler);
+ button.classList.add("pointer-events-auto");
+ return button;
+ }
+
+ handleTrustAuthorClick(event) {
+ event?.preventDefault?.();
+ event?.stopPropagation?.();
+
+ const pubkey =
+ this.video?.pubkey || this.video?.author?.pubkey || "";
+ if (!pubkey || typeof document === "undefined") {
+ return;
+ }
+
+ try {
+ document.dispatchEvent(
+ new CustomEvent("video:trust-author", { detail: { pubkey } })
+ );
+ } catch (error) {
+ // Non-fatal: trust-author is a convenience action.
+ }
+ }
+
handleShowAnywayClick(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
@@ -720,6 +759,7 @@ export class SimilarContentCard {
actions.appendChild(showButton);
this.moderationActionButton = showButton;
this.moderationActionButtonMode = "override";
+ actions.appendChild(this.createModerationTrustAuthorButton());
}
}
diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js
index 39016da4..da66cdaa 100644
--- a/js/ui/components/VideoCard.js
+++ b/js/ui/components/VideoCard.js
@@ -156,6 +156,8 @@ export class VideoCard {
this.moderationBadgeSlot = null;
this.hiddenSummaryEl = null;
this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event);
+ this.boundTrustAuthorHandler = (event) =>
+ this.handleTrustAuthorClick(event);
this.boundModerationBlockHandler = (event) =>
this.handleModerationBlockClick(event);
this.boundModerationHideHandler = (event) =>
@@ -1210,6 +1212,44 @@ export class VideoCard {
return button;
}
+ // Account-level ("trusted creator") action shown alongside "Show anyway":
+ // turns off the web-of-trust warning for EVERY video by this creator. Fires a
+ // document event that the app handles (sets the per-author override + refresh)
+ // — no per-renderer callback plumbing needed.
+ createModerationTrustAuthorButton() {
+ const button = this.createElement("button", {
+ classNames: ["moderation-badge__action", "flex-shrink-0"],
+ attrs: {
+ type: "button",
+ "data-moderation-action": "trust-author",
+ "aria-describedby": this.getModerationBadgeId(),
+ "aria-label": "Always show videos from this creator",
+ },
+ textContent: "Always show creator",
+ });
+ button.addEventListener("click", this.boundTrustAuthorHandler);
+ return button;
+ }
+
+ handleTrustAuthorClick(event) {
+ if (event) {
+ event.preventDefault?.();
+ event.stopPropagation?.();
+ }
+ const pubkey =
+ this.video?.pubkey || this.video?.author?.pubkey || "";
+ if (!pubkey || typeof document === "undefined") {
+ return;
+ }
+ try {
+ document.dispatchEvent(
+ new CustomEvent("video:trust-author", { detail: { pubkey } }),
+ );
+ } catch (error) {
+ userLogger.warn("[VideoCard] trust-author dispatch failed", error);
+ }
+ }
+
createModerationHideButton() {
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: true,
@@ -1638,6 +1678,7 @@ export class VideoCard {
actions.appendChild(showButton);
this.moderationActionButton = showButton;
this.moderationActionButtonMode = "override";
+ actions.appendChild(this.createModerationTrustAuthorButton());
}
hasActions = true;
} else {
diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js
index e2a2d772..3d6789d5 100644
--- a/js/ui/components/VideoModal.js
+++ b/js/ui/components/VideoModal.js
@@ -251,6 +251,7 @@ export class VideoModal {
this.moderationBadgeText = null;
this.moderationActionsContainer = null;
this.moderationPrimaryButton = null;
+ this.moderationTrustAuthorButton = null;
this.moderationBlockButton = null;
this.moderationPrimaryMode = "";
this.moderationBadgeId = "";
@@ -268,6 +269,8 @@ export class VideoModal {
this.handleModerationHideClick.bind(this);
this.handleModerationBlockClick =
this.handleModerationBlockClick.bind(this);
+ this.handleModerationTrustAuthorClick =
+ this.handleModerationTrustAuthorClick.bind(this);
this.handleGlobalModerationOverride =
this.handleGlobalModerationOverride.bind(this);
this.handleGlobalModerationBlock =
@@ -556,6 +559,12 @@ export class VideoModal {
);
}
}
+ if (this.moderationTrustAuthorButton) {
+ this.moderationTrustAuthorButton.removeEventListener(
+ "click",
+ this.handleModerationTrustAuthorClick,
+ );
+ }
if (this.moderationBlockButton) {
this.moderationBlockButton.removeEventListener(
"click",
@@ -568,6 +577,7 @@ export class VideoModal {
this.moderationActionsContainer = null;
this.moderationPrimaryButton = null;
this.moderationPrimaryMode = "";
+ this.moderationTrustAuthorButton = null;
this.moderationBlockButton = null;
const previousScrollRegion = this.scrollRegion;
@@ -2009,8 +2019,28 @@ export class VideoModal {
}
actionsAttached = true;
}
+
+ if (mode === "override") {
+ const trustButton = this.ensureModerationTrustAuthorButton();
+ if (trustButton) {
+ trustButton.disabled = false;
+ trustButton.removeAttribute("aria-busy");
+ if (badgeId) {
+ trustButton.setAttribute("aria-describedby", badgeId);
+ } else {
+ trustButton.removeAttribute("aria-describedby");
+ }
+ if (actions && trustButton.parentElement !== actions) {
+ actions.appendChild(trustButton);
+ }
+ actionsAttached = true;
+ }
+ } else {
+ this.removeModerationTrustAuthorButton();
+ }
} else {
this.removeModerationPrimaryButton();
+ this.removeModerationTrustAuthorButton();
}
if (this.shouldShowModerationBlockAction(context)) {
@@ -2160,6 +2190,65 @@ export class VideoModal {
this.moderationPrimaryMode = "";
}
+ ensureModerationTrustAuthorButton() {
+ if (!this.document) {
+ return null;
+ }
+
+ if (!this.moderationTrustAuthorButton) {
+ const button = this.document.createElement("button");
+ button.type = "button";
+ button.className = "moderation-badge__action flex-shrink-0";
+ button.dataset.moderationAction = "trust-author";
+ button.textContent = "Always show creator";
+ button.setAttribute(
+ "aria-label",
+ "Always show videos from this creator"
+ );
+ button.addEventListener("click", this.handleModerationTrustAuthorClick);
+ this.moderationTrustAuthorButton = button;
+ }
+
+ return this.moderationTrustAuthorButton;
+ }
+
+ removeModerationTrustAuthorButton() {
+ const button = this.moderationTrustAuthorButton;
+ if (!button) {
+ return;
+ }
+
+ button.removeEventListener("click", this.handleModerationTrustAuthorClick);
+ if (button.parentElement) {
+ button.parentElement.removeChild(button);
+ }
+
+ this.moderationTrustAuthorButton = null;
+ }
+
+ handleModerationTrustAuthorClick(event) {
+ if (event) {
+ event.preventDefault?.();
+ event.stopPropagation?.();
+ }
+
+ const pubkey =
+ this.activeVideo?.pubkey ||
+ this.activeVideo?.author?.pubkey ||
+ "";
+ if (!pubkey || typeof document === "undefined") {
+ return;
+ }
+
+ try {
+ document.dispatchEvent(
+ new CustomEvent("video:trust-author", { detail: { pubkey } })
+ );
+ } catch (error) {
+ devLogger?.warn?.("[VideoModal] trust-author dispatch failed", error);
+ }
+ }
+
ensureModerationBlockButton() {
if (!this.document) {
return null;
diff --git a/js/ui/profileModal/ProfileModerationController.js b/js/ui/profileModal/ProfileModerationController.js
index 0f9de731..9491cbf2 100644
--- a/js/ui/profileModal/ProfileModerationController.js
+++ b/js/ui/profileModal/ProfileModerationController.js
@@ -47,6 +47,11 @@ export class ProfileModerationController {
this.moderationStatusText = document.getElementById("profileModerationStatus") || null;
this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null;
this.moderationOverridesEmpty = document.getElementById("profileModerationOverridesEmpty") || null;
+ this.authorOverrideInput = document.getElementById("profileAuthorOverrideInput") || null;
+ this.authorOverrideAddBtn = document.getElementById("profileAuthorOverrideAddBtn") || null;
+ this.authorOverrideStatus = document.getElementById("profileAuthorOverrideStatus") || null;
+ this.authorOverridesList = document.getElementById("profileAuthorOverridesList") || null;
+ this.authorOverridesEmpty = document.getElementById("profileAuthorOverridesEmpty") || null;
this.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null;
this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null;
this.moderationTrustedReportCount = document.getElementById("profileModerationTrustedReportCount") || null;
@@ -102,9 +107,24 @@ export class ProfileModerationController {
});
}
+ if (this.authorOverrideAddBtn instanceof HTMLElement) {
+ this.authorOverrideAddBtn.addEventListener("click", () => {
+ void this.handleAddAuthorOverride();
+ });
+ }
+ if (this.authorOverrideInput instanceof HTMLInputElement) {
+ this.authorOverrideInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void this.handleAddAuthorOverride();
+ }
+ });
+ }
+
if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") {
this.boundModerationOverridesUpdate = () => {
this.refreshModerationOverridesUi();
+ this.refreshAuthorOverridesUi();
};
document.addEventListener(
"video:moderation-override",
@@ -569,6 +589,159 @@ export class ProfileModerationController {
}
}
+ setAuthorOverrideStatus(message) {
+ if (this.authorOverrideStatus instanceof HTMLElement) {
+ this.authorOverrideStatus.textContent = message || "";
+ }
+ }
+
+ async handleAddAuthorOverride() {
+ const input =
+ this.authorOverrideInput instanceof HTMLInputElement
+ ? this.authorOverrideInput.value.trim()
+ : "";
+ if (!input) {
+ this.setAuthorOverrideStatus("Enter a creator npub.");
+ return false;
+ }
+ const hex = this.mainController.normalizeHexPubkey(input);
+ if (!hex) {
+ this.setAuthorOverrideStatus("That doesn't look like a valid npub.");
+ return false;
+ }
+ if (
+ typeof this.mainController.services.setAuthorModerationOverride !==
+ "function"
+ ) {
+ return false;
+ }
+ try {
+ this.mainController.services.setAuthorModerationOverride(hex);
+ if (this.authorOverrideInput instanceof HTMLInputElement) {
+ this.authorOverrideInput.value = "";
+ }
+ this.setAuthorOverrideStatus("");
+ this.refreshAuthorOverridesUi();
+ this.mainController.showSuccess("Warnings turned off for this creator.");
+ return true;
+ } catch (error) {
+ this.mainController.showError(
+ "Couldn't add that creator. Please try again.",
+ );
+ return false;
+ }
+ }
+
+ async handleAuthorOverrideReset(entry) {
+ if (
+ !entry ||
+ !entry.authorPubkey ||
+ typeof this.mainController.services.clearAuthorModerationOverride !==
+ "function"
+ ) {
+ return false;
+ }
+ try {
+ this.mainController.services.clearAuthorModerationOverride(
+ entry.authorPubkey,
+ );
+ this.refreshAuthorOverridesUi();
+ this.mainController.showSuccess("Warnings restored for this creator.");
+ return true;
+ } catch (error) {
+ this.mainController.showError("Unable to reset this creator.");
+ return false;
+ }
+ }
+
+ refreshAuthorOverridesUi() {
+ if (
+ !(this.authorOverridesList instanceof HTMLElement) ||
+ !(this.authorOverridesEmpty instanceof HTMLElement)
+ ) {
+ return;
+ }
+
+ const services = this.mainController.services;
+ const raw =
+ typeof services.getAuthorModerationOverrides === "function"
+ ? services.getAuthorModerationOverrides()
+ : [];
+ const entries = (Array.isArray(raw) ? raw : [])
+ .map((entry) => ({
+ authorPubkey:
+ typeof entry?.authorPubkey === "string"
+ ? this.mainController.normalizeHexPubkey(entry.authorPubkey) ||
+ entry.authorPubkey
+ : "",
+ updatedAt: Number.isFinite(entry?.updatedAt)
+ ? Math.floor(entry.updatedAt)
+ : 0,
+ }))
+ .filter((entry) => entry.authorPubkey)
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
+
+ this.authorOverridesList.textContent = "";
+
+ if (!entries.length) {
+ this.authorOverridesEmpty.classList.remove("hidden");
+ this.authorOverridesList.classList.add("hidden");
+ return;
+ }
+
+ this.authorOverridesEmpty.classList.add("hidden");
+ this.authorOverridesList.classList.remove("hidden");
+
+ const entriesNeedingFetch = new Set();
+
+ entries.forEach((entry) => {
+ const item = document.createElement("li");
+ item.className = "card profile-account-card";
+
+ const authorKey = entry.authorPubkey;
+ if (
+ authorKey &&
+ typeof services.getProfileCacheEntry === "function" &&
+ !services.getProfileCacheEntry(authorKey)
+ ) {
+ entriesNeedingFetch.add(authorKey);
+ }
+
+ const summaryData =
+ this.mainController.dmController.resolveProfileSummaryForPubkey(
+ authorKey,
+ );
+ const summary =
+ this.mainController.dmController.createCompactProfileSummary(
+ summaryData,
+ );
+
+ const actions = document.createElement("div");
+ actions.className = "profile-account-card__actions";
+ const resetButton = this.mainController.createRemoveButton({
+ label: "Reset",
+ onRemove: () => this.handleAuthorOverrideReset(entry),
+ });
+
+ if (summary) {
+ item.appendChild(summary);
+ }
+ if (resetButton) {
+ actions.appendChild(resetButton);
+ item.appendChild(actions);
+ }
+
+ this.authorOverridesList.appendChild(item);
+ });
+
+ if (
+ entriesNeedingFetch.size &&
+ typeof services.batchFetchProfiles === "function"
+ ) {
+ services.batchFetchProfiles(entriesNeedingFetch);
+ }
+ }
+
refreshModerationSettingsUi() {
const service = this.getModerationSettingsService();
if (!service) {
@@ -620,6 +793,7 @@ export class ProfileModerationController {
this.updateTrustedHideControlsVisibility();
this.updateModerationTrustStats();
this.refreshModerationOverridesUi();
+ this.refreshAuthorOverridesUi();
this.applyModerationSettingsControlState({ resetStatus: true });
}
diff --git a/js/ui/profileModalContract.js b/js/ui/profileModalContract.js
index 84b340e4..ad941dad 100644
--- a/js/ui/profileModalContract.js
+++ b/js/ui/profileModalContract.js
@@ -510,6 +510,27 @@ const SERVICE_CONTRACT = [
"Clears a specific moderation override when the viewer resets a decision.",
fallback: () => () => false,
},
+ {
+ key: "getAuthorModerationOverrides",
+ type: "function",
+ description:
+ "Returns the account-level (per-creator) web-of-trust overrides for the active viewer.",
+ fallback: () => () => [],
+ },
+ {
+ key: "setAuthorModerationOverride",
+ type: "function",
+ description:
+ "Adds an account-level override that suppresses WoT warnings for all of a creator's videos.",
+ fallback: () => () => null,
+ },
+ {
+ key: "clearAuthorModerationOverride",
+ type: "function",
+ description:
+ "Clears an account-level (per-creator) override so the WoT warnings return.",
+ fallback: () => () => false,
+ },
{
key: "loadVideos",
type: "function",
diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs
index efe7215f..da035e72 100644
--- a/scripts/check-file-size.mjs
+++ b/scripts/check-file-size.mjs
@@ -30,7 +30,7 @@ const GRANDFATHERED = {
"js/ui/components/VideoModal.js": 4976,
"js/app.js": 5602,
"js/channelProfile.js": 5806,
- "js/nostr/client.js": 4356,
+ "js/nostr/client.js": 4362,
"js/ui/components/VideoCard.js": 3129,
"js/nostrEventSchemas.js": 3541,
"js/ui/loginModalController.js": 2552,
@@ -63,7 +63,7 @@ const GRANDFATHERED = {
"js/services/authService.js": 1335,
"js/ui/zapController.js": 1206,
"js/services/r2Service.js": 1192,
- "js/ui/applicationBootstrap.js": 1217,
+ "js/ui/applicationBootstrap.js": 1451,
"js/ui/components/EditModal.js": 1130,
"js/services/commentThreadService.js": 1100,
"js/nostr/managers/SignerManager.js": 1160,
diff --git a/tests/author-moderation-override.test.mjs b/tests/author-moderation-override.test.mjs
new file mode 100644
index 00000000..c946949f
--- /dev/null
+++ b/tests/author-moderation-override.test.mjs
@@ -0,0 +1,62 @@
+import "./test-helpers/setup-localstorage.mjs";
+import test, { beforeEach } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ setAuthorModerationOverride,
+ getAuthorModerationOverride,
+ hasAuthorModerationOverride,
+ clearAuthorModerationOverride,
+ getAuthorModerationOverridesList,
+ loadAuthorModerationOverridesFromStorage,
+} from "../js/state/cache.js";
+
+const HEX = "a".repeat(64);
+const HEX2 = "b".repeat(64);
+
+beforeEach(() => {
+ localStorage.clear();
+ clearAuthorModerationOverride(HEX);
+ clearAuthorModerationOverride(HEX2);
+});
+
+test("set + get + has recognizes an account-level override", () => {
+ const entry = setAuthorModerationOverride(HEX, { showAnyway: true });
+ assert.equal(entry.authorPubkey, HEX);
+ assert.equal(entry.showAnyway, true);
+ assert.equal(hasAuthorModerationOverride(HEX), true);
+ assert.equal(getAuthorModerationOverride(HEX)?.showAnyway, true);
+ assert.equal(hasAuthorModerationOverride(HEX2), false);
+});
+
+test("hex input is normalized (uppercase accepted, stored lowercase)", () => {
+ setAuthorModerationOverride(HEX.toUpperCase(), { showAnyway: true });
+ assert.equal(hasAuthorModerationOverride(HEX), true, "uppercase resolves to the same entry");
+});
+
+test("clearing removes it; showAnyway:false is treated as a removal", () => {
+ setAuthorModerationOverride(HEX, { showAnyway: true });
+ assert.equal(clearAuthorModerationOverride(HEX), true);
+ assert.equal(hasAuthorModerationOverride(HEX), false);
+
+ setAuthorModerationOverride(HEX, { showAnyway: true });
+ assert.equal(setAuthorModerationOverride(HEX, { showAnyway: false }), null);
+ assert.equal(hasAuthorModerationOverride(HEX), false);
+});
+
+test("survives a reload via persist → load roundtrip", () => {
+ setAuthorModerationOverride(HEX, { showAnyway: true });
+ setAuthorModerationOverride(HEX2, { showAnyway: true });
+ // A reload repopulates the in-memory map from localStorage.
+ loadAuthorModerationOverridesFromStorage();
+ const list = getAuthorModerationOverridesList()
+ .map((entry) => entry.authorPubkey)
+ .sort();
+ assert.deepEqual(list, [HEX, HEX2].sort());
+});
+
+test("blank/invalid pubkey is ignored", () => {
+ assert.equal(setAuthorModerationOverride("", { showAnyway: true }), null);
+ assert.equal(setAuthorModerationOverride("not-a-key", { showAnyway: true }), null);
+ assert.equal(getAuthorModerationOverridesList().length, 0);
+});
diff --git a/tests/my-videos-mirror-relay-truth.test.mjs b/tests/my-videos-mirror-relay-truth.test.mjs
index 51dc3fee..0e9f34a9 100644
--- a/tests/my-videos-mirror-relay-truth.test.mjs
+++ b/tests/my-videos-mirror-relay-truth.test.mjs
@@ -9,6 +9,11 @@
// - refreshMirrorStates() looks up relay truth (batched), corrects the button
// label, AND reconciles the local flag so it becomes a correct cache.
+// Self-contained localStorage shim: the mirror flag helpers read the global
+// `localStorage`, so without this the suite only passed by relying on another
+// test file leaking the shim into the shared global (brittle load-order
+// dependency). Importing it here makes this file honest in isolation.
+import "./test-helpers/setup-localstorage.mjs";
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
diff --git a/tests/video-schema-and-conversion.test.mjs b/tests/video-schema-and-conversion.test.mjs
index efc4056c..56de0dfb 100644
--- a/tests/video-schema-and-conversion.test.mjs
+++ b/tests/video-schema-and-conversion.test.mjs
@@ -5,7 +5,10 @@ import {
NOTE_TYPES,
getNostrEventSchema,
} from "../js/nostrEventSchemas.js";
-import { convertEventToVideo } from "../js/nostr/nip71.js";
+import {
+ convertEventToVideo,
+ isAudioOnlyVideoObject,
+} from "../js/nostr/nip71.js";
test("video post schema documents nsfw and kids flags", () => {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
@@ -76,3 +79,196 @@ test("convertEventToVideo normalizes nsfw and kids booleans", () => {
assert.equal(defaultParsed.isNsfw, false);
assert.equal(defaultParsed.isForKids, false);
});
+
+// TODO #60: audio-only notes (podcasts/music published as native kind-30078)
+// must not leak into the video feed — bitvid has no player, so they
+// render as a broken . convertEventToVideo marks them invalid so every
+// feed/grid ingestion path (which already drops invalid) filters them out.
+
+test("audio-only event with imeta m audio/* is filtered as invalid", () => {
+ const podcast = {
+ id: "evt-audio-imeta",
+ content: JSON.stringify({
+ version: 3,
+ title: "Nostr Compass Podcast #20",
+ url: "https://relay.example/episode.ogg",
+ videoRootId: "root-podcast",
+ }),
+ tags: [
+ [
+ "imeta",
+ "url https://relay.example/episode.ogg",
+ "m audio/ogg",
+ "duration 5254",
+ ],
+ ],
+ };
+
+ const parsed = convertEventToVideo(podcast);
+ assert.equal(parsed.invalid, true, "audio-only note should be invalid");
+ assert.match(parsed.reason, /audio-only/);
+});
+
+test("audio-only url extension (mp3) with no imeta is filtered", () => {
+ const track = {
+ id: "evt-audio-ext",
+ content: JSON.stringify({
+ version: 3,
+ title: "A song",
+ url: "https://cdn.example/track.mp3?v=2",
+ videoRootId: "root-track",
+ }),
+ tags: [],
+ };
+
+ assert.equal(convertEventToVideo(track).invalid, true);
+});
+
+test(".ogg url WITHOUT an audio imeta is kept (ambiguous → treated as video)", () => {
+ const ambiguousOgg = {
+ id: "evt-ogg-ambiguous",
+ content: JSON.stringify({
+ version: 3,
+ title: "Theora clip",
+ url: "https://cdn.example/clip.ogg",
+ videoRootId: "root-ogg",
+ }),
+ tags: [],
+ };
+
+ const parsed = convertEventToVideo(ambiguousOgg);
+ assert.equal(parsed.invalid, false, ".ogg alone must not be filtered");
+ assert.equal(parsed.url, "https://cdn.example/clip.ogg");
+});
+
+test("a video imeta variant wins even when an audio variant is also present", () => {
+ const mixed = {
+ id: "evt-mixed",
+ content: JSON.stringify({
+ version: 3,
+ title: "Talk with audio alt",
+ url: "https://cdn.example/talk.mp4",
+ videoRootId: "root-mixed",
+ }),
+ tags: [
+ ["imeta", "url https://cdn.example/talk.mp4", "m video/mp4"],
+ ["imeta", "url https://cdn.example/talk.ogg", "m audio/ogg"],
+ ],
+ };
+
+ assert.equal(convertEventToVideo(mixed).invalid, false);
+});
+
+test("an audio url paired with a magnet is kept (torrent may be video)", () => {
+ const withMagnet = {
+ id: "evt-audio-magnet",
+ content: JSON.stringify({
+ version: 3,
+ title: "Torrented",
+ url: "https://cdn.example/preview.mp3",
+ magnet: "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
+ videoRootId: "root-magnet",
+ }),
+ tags: [["imeta", "url https://cdn.example/preview.mp3", "m audio/mpeg"]],
+ };
+
+ assert.equal(convertEventToVideo(withMagnet).invalid, false);
+});
+
+test("a normal video (mp4) event is unaffected by the audio guard", () => {
+ const video = {
+ id: "evt-video",
+ content: JSON.stringify({
+ version: 3,
+ title: "Real video",
+ url: "https://cdn.example/real.mp4",
+ videoRootId: "root-video",
+ }),
+ tags: [["imeta", "url https://cdn.example/real.mp4", "m video/mp4"]],
+ };
+
+ const parsed = convertEventToVideo(video);
+ assert.equal(parsed.invalid, false);
+ assert.equal(parsed.url, "https://cdn.example/real.mp4");
+});
+
+// isAudioOnlyVideoObject operates on a CONVERTED video object (url/magnet/tags),
+// so the same guard also filters objects rehydrated from the persisted cache
+// (which bypass convertEventToVideo) at the feed query boundary + hydration.
+
+test("isAudioOnlyVideoObject flags a cached audio object (imeta audio)", () => {
+ // Shaped like the real cached "Nostr Compass #15" note.
+ const cachedAudio = {
+ id: "64a3c44c",
+ url: "https://relay.example/f7556db6.mp3",
+ magnet: "",
+ tags: [["imeta", "url https://relay.example/f7556db6.mp3", "m audio/mpeg"]],
+ };
+ assert.equal(isAudioOnlyVideoObject(cachedAudio), true);
+});
+
+test("isAudioOnlyVideoObject flags a cached .ogg only when imeta says audio", () => {
+ const cachedOggAudio = {
+ url: "https://relay.example/ep.ogg",
+ magnet: "",
+ tags: [["imeta", "url https://relay.example/ep.ogg", "m audio/ogg"]],
+ };
+ const cachedOggNoImeta = {
+ url: "https://relay.example/ep.ogg",
+ magnet: "",
+ tags: [],
+ };
+ assert.equal(isAudioOnlyVideoObject(cachedOggAudio), true, "audio imeta → audio");
+ assert.equal(
+ isAudioOnlyVideoObject(cachedOggNoImeta),
+ false,
+ ".ogg alone is ambiguous → kept"
+ );
+});
+
+test("audio/mp4 podcast (imeta) is filtered even though it would play", () => {
+ // Real case: Nodesignal-Talk E19 — m audio/mp4 + .f4a URL. AAC-in-MP4 plays in
+ // a (audio + poster), but it's still a podcast → out of the video grid.
+ const podcast = {
+ id: "66c13a72",
+ content: JSON.stringify({
+ version: 3,
+ title: "Nodesignal-Talk E19",
+ url: "https://relay.example/talk.f4a",
+ videoRootId: "root-nodesignal",
+ }),
+ tags: [
+ ["imeta", "url https://relay.example/talk.f4a", "m audio/mp4", "duration 3309"],
+ ],
+ };
+ assert.equal(convertEventToVideo(podcast).invalid, true);
+});
+
+test("audio-only .f4a/.m4b URL with no imeta is filtered (fallback)", () => {
+ assert.equal(
+ isAudioOnlyVideoObject({ url: "https://x/a.f4a", magnet: "", tags: [] }),
+ true
+ );
+ assert.equal(
+ isAudioOnlyVideoObject({ url: "https://x/book.m4b", magnet: "", tags: [] }),
+ true
+ );
+ // .mp4/.m4v remain video and must NOT be filtered by the extension fallback.
+ assert.equal(
+ isAudioOnlyVideoObject({ url: "https://x/clip.mp4", magnet: "", tags: [] }),
+ false
+ );
+});
+
+test("isAudioOnlyVideoObject keeps a normal cached video object", () => {
+ assert.equal(
+ isAudioOnlyVideoObject({
+ url: "https://cdn.example/real.mp4",
+ magnet: "",
+ tags: [],
+ }),
+ false
+ );
+ assert.equal(isAudioOnlyVideoObject(null), false);
+ assert.equal(isAudioOnlyVideoObject({}), false);
+});
diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md
index 67080b04..07161d80 100644
--- a/todo/TODO_2026-06-20_pre-launch.md
+++ b/todo/TODO_2026-06-20_pre-launch.md
@@ -279,7 +279,7 @@ Audit started 2026-06-23. See the doc for architecture map + findings. Summary:
- [x] Comment box confirmed WORKING (the "message doesn't work" was the popover
mis-position making it hard to use — see 3a).
-### 4. View counter accuracy & reliability — DEEP AUDIT
+### 4. View counter accuracy & reliability — DEEP AUDIT — DONE 2026-06-24
Audit finding (2026-06-24): counts INFLATE. `viewCounter.js` dedupes locally by
(viewer, time-window) correctly, but two things defeated it: (a)
`generateViewEventDedupeTag` built the kind-30079 `d` tag with random entropy +
@@ -421,7 +421,7 @@ un-hides when WebTorrent flips green). The real gaps:
brand-new person, confirm the thread opens on THEM and the sent message lands
in that conversation after refresh.
-### 20. Feed / grid loading reliability — some videos missing (BUG)
+### 20. Feed / grid loading reliability — some videos missing (BUG) — FIXED (streaming/first-relay-wins render is a separate latency follow-up under #17d)
Two related intermittent symptoms; likely the same root cause (a one-shot,
all-relays-settle fetch that drops slow-relay results — see 17d).
- [x] **Profile / Channel page sometimes doesn't pull in all of a creator's
@@ -504,7 +504,7 @@ toward freshness and looked identical. Gave each a structural identity:
- [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter).
- [ ] Run `npm run test:visual` after layout changes; update baselines deliberately.
-### 41. Native browser dialogs → site notification system
+### 41. Native browser dialogs → site notification system — DONE 2026-06-30
- [x] **DONE 2026-06-30.** Replaced every `alert()`/`confirm()` (no `prompt()` existed)
with the app's notification system so dialogs are styled + consistent.
- `alert()` (13) → toasts: `this.showError` where the component already had it
@@ -584,7 +584,7 @@ toward freshness and looked identical. Gave each a structural identity:
Storage Status → switch destination → summary updates and the upload
lands in the chosen bucket; with 1 connection the picker stays hidden.
-### 45. Upload modal: suggest tags from the user's previous videos (UX)
+### 45. Upload modal: suggest tags from the user's previous videos (UX) — DONE 2026-07-01
- [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap
chips of the user's most-used past hashtags ("Reuse a tag from your videos:").
Source: `app.getUserHashtagSuggestions()` reads the user's own videos
@@ -597,7 +597,7 @@ toward freshness and looked identical. Gave each a structural identity:
for users with no past tags. Rendered on modal open. Tests:
`tests/hashtag-suggestions.test.mjs` (5, pure ranking). Lint + build green.
-### 46. Feed auto-refreshes after posting a video (UX)
+### 46. Feed auto-refreshes after posting a video (UX) — DONE 2026-06-30
- [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the
just-published legacy event into the shared client cache via a new
`nostrClient.ingestLocalVideoEvent(rawEvent)` (+ thin `nostrService` wrapper), so the
@@ -664,7 +664,7 @@ toward freshness and looked identical. Gave each a structural identity:
video → within ~2min of tab re-entry its rank + badge reflect the new
total.
-### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07)
+### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07) — DONE 2026-06-30
- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail
silently ("secret-required") — `switchProfile` called the nsec provider with no
passphrase and never prompted. Now `handleProfileSwitchRequest` detects an nsec
@@ -807,15 +807,27 @@ toward freshness and looked identical. Gave each a structural identity:
- [ ] Define the submission schema + the admin action → list-mutation mapping; reuse
the existing admin list stores (whitelist/blacklist) for the writes.
-### 24. User-level allowlist override for the Web-of-Trust mute list
-- [ ] Let a user keep (and import) a personal **allowlist** that the client ALWAYS
- shows content from, overriding the inherited WoT/trusted-mute list. So even if a
- trusted curator mutes an author, the user can force that author's content to
- appear in their own feeds. Persist it (NIP-51 list, encrypted/opt-in), make it
- importable, and apply it at the render-time moderation filter as a final
- allow-override after the WoT mute/block checks.
+### 24. User-level allowlist override for the Web-of-Trust mute list — CORE DONE 2026-07-08
+- [x] **Core render-time allow-override — DONE 2026-07-08.** A per-author (per-npub)
+ override that ALWAYS shows a creator's content, overriding the inherited
+ WoT/trusted-mute warning (blur/hide) for every one of that author's videos until
+ reset. Stored in a dedicated cache (`bitvid:moderationAuthorOverrides:v1`,
+ `js/state/cache.js`) and applied as the final allow-override at the single
+ render-time integration point (`js/services/moderationDecorator.js` — sets
+ `overrideActive` when `getAuthorModerationOverride(pubkey).showAnyway`). Two entry
+ points: (a) the **Safety tab** "Trusted creators" npub input + list with per-row
+ Reset (`ProfileModerationController`), and (b) an **"Always show creator"** button
+ on every blurred/warned surface (VideoCard, VideoModal, SimilarContentCard) that
+ dispatches a `video:trust-author` event handled once in `applicationBootstrap.js`.
+ Tests: `tests/author-moderation-override.test.mjs` (5). Committed a0131c71 (Safety
+ tab) + 5844a36a (overlay button).
+- [ ] **Remaining (deferred, not blocking launch): NIP-51 sync + import.** The override
+ is currently **device-local** (localStorage), not a published NIP-51 list, so it
+ doesn't roam across devices and can't be imported/shared. Follow-up: persist as an
+ encrypted/opt-in NIP-51 list and make it importable, reusing the cross-login sync
+ machinery (#15).
-### 25. Per-video / per-event admin block list (granular moderation)
+### 25. Per-video / per-event admin block list (granular moderation) — DONE 2026-07-01
- [x] **DONE 2026-06-30 (full vertical slice).** Admins (any editor — same gate as the
author blacklist) can now hide a SINGLE video without blocking its author.
- **Record:** a published kind-30000 NIP-51 list under d-tag
@@ -996,7 +1008,7 @@ to rescue a creator who *does* get WoT-hidden, so if it happens, ship this promp
a signed Nostr event per Blossom rather than S3 keys. Research the BUD spec
coverage needed for bitvid's upload/delete flows.
-### 34. NIP-71 mirror: videos show "not mirrored" + duplicate on re-mirror (BUG)
+### 34. NIP-71 mirror: videos show "not mirrored" + duplicate on re-mirror (BUG) — FIXED 2026-06-29 (only live Amber VERIFY remains)
Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirror.
- [ ] **Mirrored videos report as NOT mirrored.** All videos were mirrored, but the UI
now shows them un-mirrored even though the NIP-71 mirror events exist. The
@@ -1772,7 +1784,7 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
remembered (disk vs session tier) in the UI, and consider a "keep unlocked" toggle
in the storage/wallet panes for users who set it up there rather than via a prompt.
-### 52. After editing a video, the video grids don't refresh to show the update (BUG)
+### 52. After editing a video, the video grids don't refresh to show the update (BUG) — FIXED 2026-07-02
- [x] **FIXED 2026-07-02.** The edit flow already re-ran `loadVideos()`, but the cache
still held the PRE-edit version: unlike new publishes (#46), the edit never
ingested its signed replaceable event locally, so grids re-rendered stale until
@@ -1781,7 +1793,7 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
store + `videos:updated`), so grids show the new title/thumbnail/etc.
immediately. Test: `tests/services/nostr-service.test.mjs` (+1).
-### 53. Channel-profile link in the video player modal doesn't work (BUG)
+### 53. Channel-profile link in the video player modal doesn't work (BUG) — FIXED 2026-07-02
- [x] **FIXED 2026-07-02.** Event-name mismatch: `VideoModal.handleCreatorNavigation`
dispatched `"navigate:profile"`, but ModalManager only listens for
`"creator:navigate"` → the click went into the void (same class as the old
@@ -1792,8 +1804,8 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
`tests/video-modal-controllers.test.mjs` (+2 — dispatches creator:navigate with
the pubkey, never the dead event; no-op without a pubkey). Lint + build green.
-### 54. Like / dislike buttons in the video player modal don't work (BUG)
-- [ ] The reaction (like/dislike) buttons in the player modal are unresponsive / don't
+### 54. Like / dislike buttons in the video player modal don't work (BUG) — FIXED 2026-07-02 (only live VERIFY remains)
+- [x] The reaction (like/dislike) buttons in the player modal are unresponsive / don't
persist. Audit `reactionController` wiring into the video modal (event binding,
active-signer availability, publish path). Note: reactions require signing — verify
this isn't another "lost signer after refresh" (#57) surfacing as a dead button; if
@@ -1976,3 +1988,89 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
- [ ] **Phase 4:** fresh-npub bootstrap — "Create account" via the existing generate
provider with a REQUIRED key-backup step, then inline kind-0 profile setup
(name + avatar), then the tour.
+
+### 60. Audio tab — podcasts / music / audio support (FUTURE) — full plan in docs/audio-integration-plan.md
+Reported 2026-07-09. Today an audio note (e.g. a podcast published as a native
+kind-30078 with `imeta … m audio/ogg` + an `.ogg` URL) leaks into the video grid
+and can't play: `convertEventToVideo` accepts it (it has a `url`), `VideoCard` gives
+it a `data-play-url`, but the source is audio-only. bitvid maps `.ogg → video/ogg`
+and hands it to a `` element → black frame + audio on Chrome/FF, or
+`MEDIA_ERR_SRC_NOT_SUPPORTED` on Safari/iOS. There is no ``/podcast player.
+The NIP-71 *ingest* guard (`nip71IngestAdapter.js` — "exclude audio/image/etc.")
+only covers ingested kinds 21/22/34235/34236, not the native 30078 path. Real
+example: event `95053d75…` "Nostr Compass Podcast #20" (pubkey `b7ed68b0…`).
+- [x] **Near-term (launch hygiene) — DONE 2026-07-09.** `convertEventToVideo`
+ (`js/nostr/nip71.js`) now marks audio-only notes `invalid` (reason
+ "audio-only source (no video)") so every feed/grid ingestion path that already
+ drops invalid filters them out: main feed buffer (`videoEventBuffer.js:145`),
+ author/channel batch fetch (`client.js:4192`), local ingest (`client.js:4500`).
+ Audio-only = no magnet AND no `imeta m video/*` AND (an `imeta m audio/*`
+ variant OR an unambiguous audio URL `.mp3/.m4a/.aac/.wav/.flac/.opus/.oga/.weba`).
+ `.ogg` is NOT filtered on extension alone (ambiguous → video/ogg) — only when
+ an `imeta m audio/*` says so. A video imeta variant always wins. Tests:
+ `tests/video-schema-and-conversion.test.mjs` (+5, 8/8). NOTE: the direct
+ open-by-nevent path (`client.js:4129`) still resolves an audio event on
+ purpose — an explicit link should still load; only feeds are filtered.
+- [ ] **The feature:** a dedicated **Audio** sidebar tab (podcasts / music / audio)
+ with its own player. Detect `audio/*` sources and render an `` UI
+ (poster from the `image`/thumbnail tag + transport controls, duration, seek)
+ instead of the `` element. This event already carries the metadata a
+ good audio UI wants: `title`, `image`, `duration`, `summary`, `channel`,
+ `show`, plus podcast `t` tags — so cards can show cover art + show/episode.
+- [ ] **Discovery/schema — audio kinds & NIPs to support (surveyed 2026-07-09).**
+ bitvid supports NIP-71 video (21/22/34235/34236) + native 30078; nothing
+ audio-specific yet. Standard audio surfaces bitvid does NOT support:
+ - **NIP-A0 Voice Messages — `kind 1222` (root) + `kind 1244` (reply).** Native
+ short audio notes: content = URL to an audio file (rec. `audio/mp4`/.m4a,
+ ≤60s). The closest thing to a first-class "audio note." Best Audio-tab target.
+ - **NIP-73 External Content IDs — Podcast GUIDs.** `i`/`k` tags:
+ `podcast:guid` (feed), `podcast:item:guid` (episode),
+ `podcast:publisher:guid`. THE bridge to Podcasting 2.0 / PodcastIndex /
+ Fountain — lets bitvid reference real podcast feeds/episodes, not just
+ bespoke re-uploads. Key for "podcast feeds."
+ - **NIP-53 live audio — `kind 30311` + `kind 30312` (audio rooms/spaces).**
+ Live audio overlaps #16 (which should own live video *and* audio).
+ - **NIP-71 audio variants** — `imeta m audio/*` + `waveform` (audio-only) are
+ already in the NIP-71 spec bitvid parses, so audio-as-a-variant is close.
+ - **De-facto today:** the podcasts we currently filter (Nostr Compass,
+ Nodesignal) are native **kind-30078 with `imeta m audio/*`** — a bespoke
+ re-upload, NOT a standard audio kind.
+ - **Non-standard music** (Wavlake / Stemstr) use app-specific custom kinds, not
+ ratified NIPs → treat as opt-in integrations, not core.
+ Decide the canonical audio signal (support 1222/1244 + native 30078 `m audio/*`
+ to start; NIP-73 refs + NIP-53 live audio later); consider a `t` marker
+ (`audio`/`podcast`/`music`) parallel to `t:video` for a clean feed filter, and
+ gate the tab behind its own config flag (mirrors the streaming tab in #16).
+ NOTE: earlier drafts said "NIP-54-style podcast events" — WRONG, NIP-54 is Wiki;
+ the real refs are NIP-73 (podcast GUIDs) + NIP-A0 (voice messages).
+- [ ] **Channel-profile category tabs (requested 2026-07-09).** The channel profile
+ should carry the same media categories as a tab strip so a creator's page stays
+ neat and self-explanatory instead of mixing everything into one grid:
+ - **Videos** (current default — kind 30078 video / NIP-71 21+34235),
+ - **Audio** (audio-only notes — podcasts / music; the #60 audio player),
+ - **Live** / **Streams** (NIP-53 kind 30311 — see #16; show live now + past),
+ - **Shorts** (NIP-71 kind 22 short-form vertical — see #16).
+ Each tab is its **own category playlist** that only loads/renders when that tab
+ is selected (lazy per-tab fetch, no cross-contamination — the audio guard keeps
+ Audio out of Videos and vice-versa). Only show a tab when the creator actually
+ has content of that type (hide empty categories). Mirror the same tab set on the
+ main feed/sidebar so the sidebar category ↔ channel tab stay consistent.
+- [ ] **Profile modal: "My Videos" → "My Content" with tabs (requested 2026-07-09).**
+ The creator-side management view (`js/ui/profileModal/MyVideosController.js`)
+ is the owner mirror of the channel-profile tabs: rename the pane/label to
+ **My Content** and give it the same category tabs (Videos / Audio / Live /
+ Shorts), each listing only that category's notes. Per-tab keeps the management
+ rows (edit, delete/undelete, mirror-to-apps toggle, storage status) scoped and
+ uncluttered. Reuse the shared category resolver below so an item shows under the
+ same tab here as on the public channel. Keep the empty-category hiding, or show
+ an empty-state CTA ("Publish your first audio note") per tab.
+- [ ] **Shared category model:** define one media-category resolver
+ (`video | audio | live | short`) derived from kind + `imeta m` + `t` markers,
+ used by the sidebar feeds, the public channel tabs, AND the profile-modal
+ "My Content" tabs, so a note lands in exactly one category everywhere. This is
+ the natural home for the #17 unified media grid.
+- [ ] **Cross-refs:** shares the "new media type behind a sidebar tab + config flag"
+ shape with #16 (live streams / NIP-53, incl. kind-22 shorts) and the #17 unified
+ media grid — plan the category resolver + per-tab loaders together so video /
+ audio / live / shorts don't each grow a bespoke fetch, and so the channel-profile
+ tabs and sidebar tabs share one source of truth.
diff --git a/todo/TODO_2026-07-09_design-sync.md b/todo/TODO_2026-07-09_design-sync.md
new file mode 100644
index 00000000..b37a1158
--- /dev/null
+++ b/todo/TODO_2026-07-09_design-sync.md
@@ -0,0 +1,86 @@
+# TODO — Design-Sync: port bitvid's design system → Claude Design
+
+Started 2026-07-09. Tracks the incremental port of bitvid's token + component
+system into the **Claude Design** project so it becomes a shared, faithful,
+versioned source-of-truth (readable by Claude when building bitvid UI and, later,
+other apps in the same visual language).
+
+## What this is (and isn't)
+
+- **Is:** a curated, pixel-faithful reproduction of bitvid's real design system as
+ a gallery of self-contained preview cards in a Claude Design project. Each card
+ is rebuilt from the actual `css/tokens.css` values + `tailwind.source.css`
+ component rules + component JS markup.
+- **Is not:** a one-shot automated export, and not a runtime package other apps
+ `import`. The project is a *reference library*; the repo stays the code source.
+- Sync is **incremental — one component at a time, never a wholesale replace**
+ (the DesignSync tool enforces this).
+
+## Project + tooling
+
+- Project: **"bitvid Design System"** — `type: PROJECT_TYPE_DESIGN_SYSTEM`
+ - `projectId`: `6b3b1e2e-8d55-4b95-873a-52b6d886c186`
+- Tool: `DesignSync` (claude.ai/design). One-time auth per session via `/design-login`.
+- **The project itself is the durable store.** Card sources this pass were staged
+ in the session scratchpad (ephemeral) — to edit a card later, `get_file` it back,
+ edit, then `finalize_plan` → `write_files`. Don't rely on scratchpad persisting.
+
+## Card conventions (follow for every new card)
+
+- First line MUST be ``
+ (the pane compiles the card index from these markers on its self-check).
+- **Self-contained + pixel-faithful:** resolve every Tailwind `@apply` to concrete
+ CSS; drive all values from the real tokens (`--space-*`, `--radius-*`, `--status-*`,
+ the exact admin ring/star box-shadow, `--color-zap`, …). No approximations.
+- Link the token files (`../tokens/*.css`) + `../styles.css` so `var()` resolves and
+ both themes work (tokens carry `[data-theme="light"|"dark"]` scopes).
+- External images are blocked in the render sandbox → gradient placeholders for
+ thumbnails; reuse in-project assets (`../assets/svg/default-profile.svg`) for avatars.
+
+## Done
+
+### Seeded by the UI generator (pre-existing, verified accurate)
+- [x] Token foundation `tokens/{colors,semantic,typography,spacing,effects,fonts}.css`
+ — a faithful ~155-token curated subset of the real 670-prop `tokens.css`;
+ values match production exactly (accent indigo `rgb(99 102 241)`, dark surfaces,
+ `--color-zap` btc-orange, spacing/radii/type all verified). Both themes.
+- [x] **Colors** group (5 cards): accent, status, surfaces-dark, surfaces-light, text.
+- [x] Real bitvid assets/logos imported (`assets/`, `bitvid_logo/`).
+
+### Pass 1 — foundation backfill + flagship components (synced 2026-07-09)
+- [x] **Foundations** group (5 cards): `typography` (Inter/Fira Code + full type scale),
+ `spacing` (4xs→2xl-plus ramp), `radii` (incl. sidebar/modal shells), `shadows`
+ → "Elevation" (sm→modal + zap popover), `motion` (live easing curves).
+- [x] **Components** group (2 cards):
+ - `video-card` — default (admin creator: gold ring + corner star), Private
+ (`data-state="private"`), Moderation-blurred (warning `moderation-badge`
+ overlay w/ "Show anyway" / "Always show creator").
+ - `nostr-profile` — compact account card (`createCompactProfileSummary` +
+ `.profile-account-card`): subscription, admin-override (ring+star), blocked.
+
+## Queued (expansion, roughly value-ordered)
+
+- [ ] **Group A — overlays/menus:** the `⋯` more-menu / dropdown, notification toast
+ (`appNotify` / NotificationController), confirm + prompt dialogs
+ (`confirmDialog.js` / `promptDialog.js` / `showPasswordPrompt`).
+- [ ] **Group B — badges & pills:** `moderation-badge` neutral variant, standalone
+ admin star, sats/zap badge, acting-as pill, `.pill`.
+- [ ] **Group C — buttons & form controls:** `.form-control`, `.btn-ghost`
+ (accent/danger), primary button, focus-ring treatment.
+- [ ] **Group D — modal shell** → then the big modals: upload, profile, login,
+ share-nostr, embed-video.
+- [ ] **Group E — larger surfaces:** sidebar/nav (`sidebar.html`), account switcher,
+ video player modal, DM surfaces, search/filter modal.
+
+## Foundational gaps to capture later
+
+- [ ] **Red "override" accent theme** — `--color-accent-override: rgb(254 0 50)` +
+ strong/pressed. bitvid ships an alternate red accent; not in the curated set.
+- [ ] Blur tokens (`--blur-*`), breakpoints (`--breakpoint-*`), avatar sizes,
+ component-sizing tokens — reference cards if/when a component needs them.
+
+## Verify (user)
+
+- [ ] Open claude.ai/design → bitvid Design System; confirm the 7 new cards render
+ in both themes and the admin ring/star + zap-orange read correctly. If any
+ card is missing from the index, it needs explicit `register_assets`.