mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
commit
c206ec39bd
25 files changed
+1455
-30
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`).
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
buildNip71VideoEvent,
|
||||
collectNip71PointerRequests,
|
||||
convertEventToVideo,
|
||||
isAudioOnlyVideoObject,
|
||||
extractNip71MetadataFromTags,
|
||||
getDTagValueFromTags,
|
||||
mergeNip71MetadataIntoVideo,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user