Promote unstable → beta

WoT per-creator override (#24, both entry points), audio-only feed guard
(#60 near-term: ingest + cache-hydration + render filters, incl. .f4a/.m4b),
FEATURE_AUDIO_INGEST flag (default off), the audio/music integration plan +
locked decisions, the #34 mirror test fix, and pre-launch TODO de-staling.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-09 10:24:07 -04:00
commit c206ec39bd
25 files changed
+1455 -30

No files matched your search

+50
View File
@@ -1612,6 +1612,56 @@
data-orientation="vertical"
></ul>
</div>
<div class="space-y-3 border-t border-border/60 pt-4">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-text">
Trusted creators
</h4>
<p class="text-xs text-muted">
Turn off web-of-trust warnings for a creator across all
their videos. They stay listed here until you reset them.
</p>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="min-w-0 flex-1 space-y-1">
<span class="text-xs font-medium text-muted"
>Creator npub</span
>
<input
id="profileAuthorOverrideInput"
type="text"
class="form-control"
placeholder="npub1…"
autocomplete="off"
spellcheck="false"
/>
</label>
<button
id="profileAuthorOverrideAddBtn"
type="button"
class="btn focus-ring"
>
Always show
</button>
</div>
<p
id="profileAuthorOverrideStatus"
class="text-xs text-muted"
role="status"
aria-live="polite"
></p>
<div
id="profileAuthorOverridesEmpty"
class="card border border-dashed border-border/60 p-4 text-center text-xs text-muted"
>
No trusted creators yet.
</div>
<ul
id="profileAuthorOverridesList"
class="grid-stack hidden"
data-orientation="vertical"
></ul>
</div>
</div>
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
+12
View File
@@ -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:
+203
View File
@@ -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 `<audio>` player**. Podcasts/music published as
native 30078 with `imeta m audio/*` currently render as a broken `<video>`, 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
> `<video>` for an `<audio>` 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
`<audio>` + 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
`<audio>` element degrades better than `<video>` 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`).
+5
View File
@@ -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(
+2
View File
@@ -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 };
+11
View File
@@ -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
+8 -3
View File
@@ -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
+1
View File
@@ -18,6 +18,7 @@ export {
buildNip71VideoEvent,
collectNip71PointerRequests,
convertEventToVideo,
isAudioOnlyVideoObject,
extractNip71MetadataFromTags,
getDTagValueFromTags,
mergeNip71MetadataIntoVideo,
+7
View File
@@ -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);
+71
View File
@@ -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 <mime>` 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 <audio> player, so these
// render as a broken <video> 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 <audio> player, so they render as a broken
// <video>. 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);
+12 -4
View File
@@ -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
? {
+8 -1
View File
@@ -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 <video> 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) {
+170
View File
@@ -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";
+61
View File
@@ -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);
+40
View File
@@ -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());
}
}
+41
View File
@@ -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 {
+89
View File
@@ -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;
@@ -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 });
}
+21
View File
@@ -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",
+2 -2
View File
@@ -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,
+62
View File
@@ -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);
});
@@ -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";
+197 -1
View File
@@ -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 <audio> player, so they
// render as a broken <video>. 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 <video> (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);
});
+117 -19
View File
@@ -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 `<video>` element → black frame + audio on Chrome/FF, or
`MEDIA_ERR_SRC_NOT_SUPPORTED` on Safari/iOS. There is no `<audio>`/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 `<audio>` UI
(poster from the `image`/thumbnail tag + transport controls, duration, seek)
instead of the `<video>` 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.
+86
View File
@@ -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 `<!-- @dsCard group="…" viewport="WxH" name="…" subtitle="…" -->`
(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`.