Commit Graph
100 Commits
Author SHA1 Message Date
thePR0M3TH3AN 4eaa78cf59 docs: add BitUnlock permission layer TODO 2026-07-23 21:32:49 -04:00
thePR0M3TH3AN f49080558b docs: add BitUnlock permission layer plan 2026-07-23 21:31:57 -04:00
thePR0M3TH3AN 61f7d3f15e Promote beta → main: upload-artifact→v7, download-artifact→v8 (Node 24, clears remaining deprecation) 2026-07-14 10:11:48 -04:00
thePR0M3TH3AN 1d14a1f392 Promote unstable → beta: upload-artifact→v7, download-artifact→v8 (Node 24, clears remaining deprecation) 2026-07-14 10:11:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 676844edfc ci: bump upload-artifact→v7 and download-artifact→v8 for Node 24
The v5 line of these two actions still targets Node.js 20 (unlike
checkout/setup-node@v5, which are already on Node 24), so the deprecation
warning persisted. upload-artifact reaches Node 24 at v6+ and
download-artifact at v7+; move to the current latest (v7 / v8), which
share the same v4-generation artifact backend and pair correctly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DwbE3uTiFaKRNjx5vP2muV
2026-07-14 10:11:44 -04:00
thePR0M3TH3AN 1340ad666b Promote beta → main: bump GitHub Actions (checkout/setup-node/upload+download-artifact) v4 → v5 to clear Node 20 deprecation 2026-07-14 10:09:56 -04:00
thePR0M3TH3AN ada954b99c Promote unstable → beta: bump GitHub Actions (checkout/setup-node/upload+download-artifact) v4 → v5 to clear Node 20 deprecation 2026-07-14 10:09:48 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c35b60c539 ci: bump checkout/setup-node/upload+download-artifact v4 → v5
The v4 actions target Node.js 20, which GitHub now force-runs on Node 24
and flags as deprecated. Bump all four across every workflow to v5 (their
native Node-24 line) to clear the warning. Mechanical version-only change;
upload/download-artifact stay on matching majors as required.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DwbE3uTiFaKRNjx5vP2muV
2026-07-14 10:09:42 -04:00
thePR0M3TH3AN feee092a4d Promote beta → main: nsite site-archive automation — on-push downloadable artifact (all branches) + GitHub Release with bitvid-site.zip on main (stable /releases/latest/download URL); manual-publish plan doc 2026-07-14 09:36:43 -04:00
thePR0M3TH3AN 7cc6aab20d Promote unstable → beta: nsite site-archive automation — on-push downloadable artifact (all branches) + GitHub Release with bitvid-site.zip on main (stable /releases/latest/download URL); manual-publish plan doc 2026-07-14 09:36:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bff716948d ci(nsite): cut a GitHub Release with the site archive on main pushes
Add a `release` job (main only, contents: write) that builds the archive
and publishes it as an immutable GitHub Release tagged
site-<date>-<shortsha>, marked --latest, with bitvid-site.zip attached.
This gives a stable download URL for the current production archive:
  https://github.com/PR0M3TH3AN/bitvid/releases/latest/download/bitvid-site.zip

Re-runs of the same commit refresh the asset instead of failing. The
Release holds only the public compiled dist/ (same as Vercel serves) and
no Nostr signing key — publishing to nsite stays a manual, client-signed
step. Per-branch artifact upload is unchanged. Docs updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DwbE3uTiFaKRNjx5vP2muV
2026-07-14 09:35:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 10b93efa02 ci(nsite): build a downloadable site archive on every push
Add .github/workflows/nsite-archive.yml (push to any branch +
workflow_dispatch): npm ci + npm run build, then upload dist/ as an
artifact (bitvid-site-<branch>-<shortsha>, 30d retention). GitHub serves
it as a .zip with index.html at the root, ready for a manual nsite
publish. The workflow holds no signing key and publishes nothing.

Also add an `archive` npm script for the same output locally
(bitvid-site.zip, now gitignored), and land docs/nsite-mirror-plan.md
revised to the manual-publish + auto-archive model: sign client-side via
NIP-07 or local nsyte, never an nsec/nbunksec in CI. The prior CI-signing
workflow is retained as a labeled Phase-3 reference option.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DwbE3uTiFaKRNjx5vP2muV
2026-07-14 09:29:41 -04:00
thePR0M3TH3AN 0dd7072b12 merge: promote beta → main — Blossom companion-event P2P, blob management, storage fixes (#30) 2026-07-12 22:07:11 -04:00
thePR0M3TH3AN 5313675896 merge: promote unstable → beta — Blossom companion-event P2P, blob management (BUD-03/BUD-12), storage sync + selector fixes (#30) 2026-07-12 22:07:02 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e81e65ff40 feat(blossom): Phase 2 management — blob delete-on-orphan + kind-10063 server list (#30)
- deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit).

- BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 10:32:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 51072e4871 fix(storage): let a Blossom-only (keyless) account sync across devices
importAccountRecord hard-required encryptedMasterKey, but a Blossom-only account is keyless (no encrypted payloads) and legitimately has none — so its synced record failed to import. Require the envelope ONLY when the record carries an encrypted connection that actually needs it to unlock.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 10:22:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ef2e48912d fix(playback): P2P peer discovery for Blossom videos + forced-torrent URL fallback (#30)
Two torrent-path robustness fixes surfaced while testing WebTorrent peering:

- streamVideo now sets announce: [...WSS_TRACKERS]. A .torrent-buffer add (Blossom companion piece-map) has an EMPTY embedded announce list, so without this it reached no tracker, found no WebRTC peers, and streamed webseed-only (looked like a stuck '1 peer'). WebTorrent merges these with any trackers already on the source.

- A forced-torrent playback now falls back to the hosted URL when the torrent DEFINITIVELY fails (e.g. a transient companion-lookup failure under relay congestion, or a dead swarm) instead of hard-erroring 'No playable source found'. Only triggers on real failure, not a slow swarm. Rollback noted inline.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 19:04:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4906745bff feat(storage): live-refresh the open upload modal when connections sync from Nostr
storageService now emits onConnectionsChanged (from importAccountRecord, the sync-import path); the upload modal subscribes and, when open + active user, re-derives its storage destination in place (re-attempt auto-unlock, recompute unlocked state, reload, repaint) — so credentials synced from Nostr are reflected without reopening. Bumped UploadModal's file-size baseline for the growth.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 19:04:06 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d23cfa6697 feat(blossom): P2P for Blossom videos via a WebTorrent-metadata companion event (#30)
Blossom servers reject a .torrent (415), so a Blossom video's magnet is webseed-only and can't bootstrap WebTorrent. This restores P2P without hosting a .torrent:

- Upload hardening: correct Content-Type (overrides empty/generic application/octet-stream), HTTP status surfaced in errors, and best-effort .torrent sidecar so a rejected sidecar no longer sinks the upload (URL-first fallback).

- Publish: when the .torrent can't be hosted, publish the piece-map as an infohash-keyed companion event (kind 30078, d=bitvid:torrent:<infohash>) and ship a ws= magnet. Gated by FEATURE_BLOSSOM_TORRENT_METADATA (default off = no trace); size-capped.

- Playback: torrentMetadataService lazily fetches the companion, VERIFIES the reconstructed infohash === the magnet's btih (untrusted content), sources the webseed from the video event, and hands the .torrent buffer to WebTorrent so it streams with zero peers. Relay reads go through the subscription manager.

Inherited by any upload-based media (shorts, audio-publish); excludes ingest/live. See docs/blossom-torrent-metadata-plan.md. Includes a Prettier reflow in css/tailwind.source.css.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 14:08:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 56aff1ada0 fix(storage): default the storage-tab provider selector to the active default connection
fillStorageForm read the provider only from conn.provider (top-level), but getConnection returns { ...payload, meta } with the provider in meta (and no top-level provider for keyless Blossom) — so the selector always fell back to cloudflare_r2. Mirror connectionProvider()'s conn.provider || conn.meta?.provider fallback so the pane reflects the loaded default connection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 14:08:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c22fa23353 fix(blossom): push the encrypted synced copy after saving Blossom servers [LOCAL] (#30)
The keyless Blossom save returned early without triggering storageSyncService.push
(the S3 path does it after saving), so changing Blossom servers never updated the
synced copy — it looked like the settings didn't sync. Blossom servers already
live in the account record's plaintext meta (which exportAccountRecord ships), so
the same push the S3 branch uses now runs after a keyless save: the record is
re-encrypted to self and published, and it roams like S3.

NOTE: sync export/import needs the account's encryptedMasterKey, which exists once
any secret-bearing (S3) connection has been unlocked/saved. A Blossom-ONLY account
(no S3 ever) has no master key envelope yet — encrypted sync for that case is a
follow-up.

Lint + build clean. Local only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 22:00:21 -04:00
thePR0M3TH3ANandClaude Opus 4.8 18ea5428eb fix(blossom): upload directly per-server instead of the failing /mirror flow [LOCAL] (#30)
Real-device failure: multiServerUpload uploads to one server then has the OTHERS
fetch it server-to-server via PUT /mirror — those /mirror requests fail from a
browser (net::ERR_FAILED / CORS), so the whole upload returned no blob URL
("Blossom upload did not return a usable blob URL").

Switch blossomService.uploadFile to upload DIRECTLY to each server (BUD-02
PUT /upload) in parallel via uploadBlob, resilient: first success wins, per-server
failures are collected, and if ALL fail the error names each server + the likely
cause (CORS / size / payment) and suggests blossom.band. A fresh signed upload
auth (BUD-11) is created per request.

Tests: blossom-service 11/11 (added partial-failure fallback + all-fail error).
Lint + build clean. Local only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 21:49:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ba62b35ada fix(storage): make Blossom actually usable in the upload modal [LOCAL] (#30)
Two root causes for "Blossom set as default but not showing / not usable":

1. SAVE required unlock. saveConnection throws "Storage is locked" without a
   master key — but Blossom has no secret. Added
   storageService.saveKeylessConnection() (writes plaintext meta, no encryption,
   no unlock) and the Blossom save branch now uses it. So a Blossom connection
   saves even if the user never unlocked storage.

2. UploadModal treated Blossom like a locked S3 connection: it only set
   activeCredentials when isStorageUnlocked, showed "Locked 🔒", blocked the
   file-pick behind an unlock prompt, and mislabeled it "S3 Compatible". Now:
   getProviderLabel → "Blossom"; loadFromStorage resolves servers from the
   plaintext meta (no unlock); the summary shows "N servers / Nostr-native"; the
   two upload gates and updateLockUi bypass unlock for Blossom
   (activeProviderIsBlossom). Uploads authorize with the Nostr signer instead.

Bumped UploadModal size baseline (+gating code). Lint + build clean. Local only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 21:40:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5935702e75 feat(storage): Blossom Phase 2 — BUD-12 delete/list primitives [LOCAL] (#30)
The management primitives, self-contained + unit-tested. Not yet wired into the
S3-shaped orphan-GC / delete-flow (that reconcile system assumes bucket/key
semantics — its own browser-tested increment, same pattern as the upload).

- Vendor entry: export createDeleteAuth (+ rebuilt bundle, still ~27KB).
- blossomService.deleteFile({ server, sha256, signer }) — BUD-12 DELETE /<sha256>
  with a signed kind-24242 delete auth (BUD-11).
- blossomService.listFiles({ server, pubkey, signer }) — BUD-12 GET /list/<pubkey>,
  optional list auth.

Tests: blossom-service 9/9 (added delete/list guards + happy paths). Lint + build
clean.

REMAINING Phase 2: wire deleteFile into video-delete cleanup (resolve
server+sha256 from the blossom storagePointer), orphan-GC parity, and the
kind-10063 discovery UI (import/publish server list — needs relay fetch/publish
plumbing).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 16:36:31 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c5e8c7f9f1 feat(storage): Blossom Phase 1b — keyless multi-server config UI [LOCAL, flag ON] (#30)
Makes Blossom configurable + uploadable end-to-end (local testing).

- Storage pane: a "Blossom (Nostr-native)" provider option (revealed only when the
  flag is on) that swaps the S3 field group (#storageS3Fields) for a keyless
  #storageBlossomSection — a multi-server textarea (one URL per line). No
  access-key/secret/bucket/CORS.
- ProfileStorageController: parseBlossomServers(); save branch persists the server
  list in the PLAINTEXT connection meta ({servers}, no encrypted secret) via the
  per-provider slot; provider-change loads servers from meta WITHOUT an unlock;
  Test does a best-effort reachability ping; visibility branch hides S3 fields +
  CORS helper for Blossom.
- mediaUploader.resolveActiveConnection: Blossom connections resolve from plaintext
  meta with no unlock (uploads authorize via the Nostr signer, not a stored key),
  so credentials = { provider, servers, meta } flow straight into the Phase-1a
  engine.
- instance-config: FEATURE_BLOSSOM_STORAGE = true for LOCAL testing (reset to false
  before pushing).

Tests: blossom-service 7/7 (isAvailable now flag-relative). Lint + build clean.
Blossom UI present in dist.

VERIFY locally: Storage → Provider → Blossom → add a server → Save → upload a
video → it plays from <server>/<sha256>. Watch the UploadModal's own storage
gating (may need a tweak for the no-secret/no-PIN Blossom case).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 16:31:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0eafd38ec4 feat(storage): Blossom Phase 1a — upload engine [LOCAL] (#30)
The Blossom upload orchestration, isolated + unit-tested. Only routes when a
connection's provider is "blossom", so the S3/R2 path is completely unaffected
(that branch can't trigger until the Phase-1b config UI can create a Blossom
connection).

- blossomService now owns the orchestration (WebTorrent-free, so it's Node-
  testable): uploadFile (multiServerUpload + BUD-11 auth) and uploadVideo (blob →
  injected torrent generator → upload .torrent as a 2nd blob → magnet with ws=the
  Blossom video url + xs=the Blossom .torrent url → blossom storagePointer). Same
  result shape as the S3 path, so publish is untouched. Exports isBlossomProvider
  + resolveBlossomServers.
- mediaUploader: thin Blossom branch at the top of uploadThumbnail/uploadVideo
  (delegates to blossomService, injecting generateTorrentMetadata so the service
  never imports WebTorrent); new getSigner dep. UploadModal + EditModal pass
  getSigner: () => getActiveSigner().

Tests: tests/blossom-service.test.mjs (7 — guards, mirror, torrent/magnet
orchestration, storagePointer). Lint + build clean. Flag still off.

NEXT (Phase 1b): the keyless multi-server Storage-pane config UI + connection
model (servers in plaintext connection meta — no secret) + resolveActiveConnection
Blossom branch, so a server can be configured and uploads run end-to-end.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 15:56:05 -04:00
thePR0M3TH3ANandClaude Opus 4.8 02e60f1842 feat(storage): Blossom Phase 0 — flag + vendored SDK + service skeleton (#30)
Phase 0 of docs/blossom-plan.md. No UX; off-by-default ("off = no trace").

- Vendored: pinned [email protected] bundled by scripts/build-blossom-sdk.mjs
  → vendor/blossom-client-sdk.bundle.min.js (esbuild, browser ESM, ~27KB). The
  optional @cashu/cashu-ts peer dep (BUD-07 payments, only reached on a lazy 402
  branch bitvid never hits) is aliased to a throwing stub. `npm run build:blossom-sdk`.
- Flag: FEATURE_BLOSSOM_STORAGE wired like FEATURE_AUDIO_INGEST / _BITCOIN_CONNECT
  (instance-config → config → constants), default FALSE.
- Service: js/services/blossomService.js implements the same
  uploadFile({…}) → {url,key} contract as s3UploadService (so a later
  isBlossomProvider branch drops into mediaUploader). Lazy-loads the SDK; core
  path = createUploadAuth(signer, sha256) + multiServerUpload (BUD-11 auth via
  bitvid's existing signer, BUD-04 mirror). Torrent parity + result-shape mapping +
  mediaUploader wiring + Storage-pane config land in Phase 1.

Tests: tests/blossom-service.test.mjs (5 — availability gate + input guards).
Lint + build clean. Nothing wired into the app yet.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 15:42:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b7c6be830f docs(blossom): lock decisions D1–D10 to recommendations
Status → DECISIONS LOCKED. Locks: vendor blossom-client-sdk v5; multi-server +
mirror; kind-10063 read (v1) / publish (v1.5); /upload for video (/media for
thumbnails later); reuse bitvid's signer; keep torrent parity; management in v2;
keyless multi-server config form; FEATURE_BLOSSOM_STORAGE (off = no trace);
public-blobs-only privacy posture.

Local commit (with the unpushed Bitcoin Connect batch); not pushed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 15:31:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 16aa7557ab docs: add Blossom storage dev plan (#30) — SDK-based, multi-server, flag-gated
Writes docs/blossom-plan.md (house format) from the July 2026 research: use the
maintained blossom-client-sdk v5 (only dep @noble/hashes, already vendored); the
spec's current modular BUD map (BUD-01 retrieval, 02 /upload, 11 auth kind-24242,
12 list/delete, 03 kind-10063 discovery, 04 mirror, 05 /media, 06 preflight);
the existing service.uploadFile seam as the integration point; and 10 decisions
(SDK vs hand-roll, multi-server+mirror, discovery, /upload vs /media, signer
reuse, torrent parity, management, keyless config UI, FEATURE_BLOSSOM_STORAGE,
public-only privacy). Notes Blossom removes the encrypted-cred-sync surface
(no secret — server URLs + signer) and the lift is medium (config UI is the main
cost). Expands TODO #30 with the plan pointer + decisions.

Local commit (kept with the unpushed Bitcoin Connect batch); not pushed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 15:23:42 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1add0bebec feat(wallet): tuck manual URI under an "Advanced" disclosure (Bitcoin Connect D5)
When FEATURE_BITCOIN_CONNECT is on, "Connect wallet" is the primary path and the
raw Wallet Connect URI field collapses under an "Advanced — paste a connection
manually" <details> disclosure (backup, de-emphasized). When the flag is off, the
controller forces the disclosure open and hides its summary, so the URI field
shows plainly exactly as before (no behavior change). Default-zap field stays
always visible. Adds a small token-based rotating-caret style for the summary.

Still LOCAL (flag ON for testing); not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 14:56:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 69e87c6e15 feat(wallet): Bitcoin Connect NWC "Connect wallet" flow [LOCAL — flag ON, do not push as-is]
Phase 0+1 of docs/bitcoin-connect-plan.md (#61). Adds Alby's Bitcoin Connect as a
one-tap NWC connect front-end that feeds bitvid's EXISTING wallet pipeline; manual
URI entry stays as the backup.

- Vendored: pinned @getalby/[email protected].3 bundled by
  scripts/build-bitcoin-connect.mjs → vendor/bitcoin-connect.bundle.min.js (esbuild,
  browser ESM). crypto-shim aliases the L402-only `import('crypto')` bitvid never
  calls. `npm run build:bitcoin-connect` rebuilds it.
- Flag: FEATURE_BITCOIN_CONNECT wired like FEATURE_AUDIO_INGEST
  (instance-config → config → constants).
- UI: a "Connect wallet" button in the Wallet Connect pane (hidden unless the flag
  is on); the manual URI field stays below as the backup.
- Controller: ProfileWalletController.handleBitcoinConnect() lazy-imports the bundle
  (only on click — the ~700KB never touches the main bundle), inits NWC-only with
  least-privilege requestMethods and persistConnection:false, then on onConnected
  extracts provider.client.nostrWalletConnectUrl → the SAME validate→save path as
  the manual field → disconnect() so bitvid solely holds the spending secret. The
  URI is never logged.

NOTE: config/instance-config.js FEATURE_BITCOIN_CONNECT is TRUE for local testing
only. Reset to false (shipping default) before pushing/deploying. Local commit;
not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 14:49:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6dd40ce330 docs: add Bitcoin Connect (NWC connect UX) integration plan + TODO #61
Requested: integrate getAlby/bitcoin-connect as a smooth NWC "connect wallet"
front-end, keeping manual URI entry as backup. Plan (docs/bitcoin-connect-plan.md)
grounds it in bitvid's existing NWC stack: BC's NWC connector exposes the raw
pairing URI (provider.client.nostrWalletConnectUrl) which feeds the existing
validateWalletUri → per-npub store → zap-split/pay_invoice pipeline unchanged.
BC is a connect adapter, not a payment layer. Covers delivery (vendor+lazy import
vs esm.sh; CSP already allows esm.sh), persistence ownership, NWC-only connector
scope, least-privilege requestMethods, UI placement, FEATURE_BITCOIN_CONNECT flag,
and the security constraints (bearer spending secret, Alpha lib pin/vendor).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 14:19:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d8d1ccb1f6 Promote unstable → beta: share popover copy buttons fix
Wire the video-modal share popover's Copy URL / Copy Magnet / Copy CDN / Share
on Nostr actions (were dead — no click delegation).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 12:47:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 793d609d6a fix(video-modal): wire the share popover's Copy URL / Magnet / CDN buttons
The share popover's menu items (Copy URL, Copy Magnet, Copy CDN Link, Share on
Nostr) only set `data-action` — like the ⋯ menu — but unlike the ⋯ menu (which
moreMenuController delegates) nothing wired their clicks, so they did nothing.
Only the bottom "forced-source" test links self-wire. Same dead-button class as
the old embed/creator-link bugs.

Add a delegated click handler on the share panel (bindShareMenuActions) that
routes each action to the event videoModalController already handles:
copy-magnet → video:copy-magnet, copy-cdn → video:copy-cdn, share-nostr →
video:share-nostr, and share (Copy URL) → a new video:copy-url (buildShareUrl
makes the plain `?v=<nevent>` deep link; controller copies it with a toast).
Forced-source links are skipped (they self-wire).

Tests: tests/video-modal-controllers.test.mjs (+5 routing scenarios, 24/24).
Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 12:46:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 19b7d8215d Promote unstable → beta: beacon spinner-on-load fix
.ds-overlay-backdrop[hidden] guard so the beacon processing overlay no longer
covers the page on load (#28 follow-up).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 12:30:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0df1f7e2ad fix(beacon): stop the processing overlay covering the page on load
beacon.html "just shows a spinner": the `data-ds="new"` redesign's
`.ds-overlay-backdrop { display: flex }` (an author rule) overrides the UA
`[hidden] { display: none }`, so the processing overlay — `hidden` in the markup
— rendered on load as a full-screen backdrop over the whole page. (Not the #28
watchdog, which is compiled into the bundle correctly.)

Add `.ds-overlay-backdrop[hidden] { display: none }` (specificity 0,2,0 beats the
base rule) so the `hidden` attribute actually hides it — the same guard the video
modal already uses. No JS change; the JS inline-style toggle still works. The
overlay now only shows while a torrent is being added. Rebuilt CSS + dist.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 12:29:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c206ec39bd 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]>
2026-07-09 10:24:07 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cd556f9c1f feat(config): wire FEATURE_AUDIO_INGEST flag + lock audio-plan decisions
Adds the Audio/Music/Podcast feature flag to the instance config so a deployer
enables/disables it in one place like the other flags. Follows the exact
FEATURE_NIP71_INGEST wiring: export in config/instance-config.js → re-export in
js/config.js → DEFAULT_FLAGS + runtimeFlags snapshot + `export let` in
js/constants.js. Defaults FALSE ("off = no trace") — only an explicit `true`
lights it up; with it off nothing audio renders and the shipped audio guard keeps
audio-only notes out of the video feeds. No consumers yet (build only).

Also locks docs/audio-integration-plan.md decisions D1–D10 to the agreed
recommendations (status → DECISIONS LOCKED).

Verified: constants + config re-export FEATURE_AUDIO_INGEST === false by default.
Lint + build clean. docs/audio-integration-plan.md, TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:19:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3d9f940a8f docs: add audio/music/podcast integration dev plan (#60)
Pulls the audio thread together into docs/audio-integration-plan.md, matching the
house format of the shorts/live plans: kind/NIP landscape (NIP-A0 voice 1222/1244,
NIP-73 podcast GUIDs, NIP-53 live audio, NIP-71 audio variants, native 30078
m audio/*, non-standard music), 10 open decisions (D1–D10) with recommendations,
config flags (FEATURE_AUDIO_INGEST, off = no trace), the shared media-category
resolver (video|audio|live|short) used by sidebar + channel + My Content tabs,
audio player model (modal → persistent mini-player), UX best practices, flag-gated
phases, moderation, V4V/zaps, risks. Links it from TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:09:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8a1a5d8214 docs(todo): #60 — correct audio NIP refs, record supported/unsupported kinds
Surveyed the local NIP mirror for audio/podcast kinds. Fixes the earlier wrong
"NIP-54-style podcast events" ref (NIP-54 is Wiki) and records the real targets:
NIP-A0 Voice Messages (kind 1222/1244), NIP-73 External Content IDs (podcast:guid
/ item / publisher — the Podcasting 2.0 bridge), NIP-53 live audio (30311/30312,
overlaps #16), NIP-71 audio variants (imeta m audio/* + waveform), and notes that
the podcasts we filter today are bespoke native kind-30078 with imeta m audio/*,
while music apps (Wavlake/Stemstr) use non-standard custom kinds.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:04:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0375bed068 docs(todo): #60 — "My Videos" → "My Content" with category tabs
Adds the creator-side mirror of the channel tabs: rename the profile-modal
My Videos pane (MyVideosController) to "My Content" with Videos/Audio/Live/Shorts
tabs, each scoping the management rows (edit/delete/mirror/storage) to one
category. Reuses the shared category resolver so items bucket identically on the
owner view and the public channel.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:01:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bae517a4b3 docs(todo): #60 — add channel-profile category tabs (Videos/Audio/Live/Shorts)
Expands the Audio-tab item with a channel-profile tab strip carrying the same
media categories (Videos / Audio / Live-Streams / Shorts), each its own lazily-
loaded per-tab playlist, empty categories hidden, mirrored on the sidebar. Adds a
shared media-category resolver (video|audio|live|short from kind + imeta m + t)
used by both the sidebar feeds and the channel tabs — the natural home for the
#17 unified media grid. Cross-refs #16 (NIP-53 live + kind-22 shorts) / #17.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 10:00:08 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0c09f2854c fix(feed): harden audio detection — audio/mp4 (.f4a/.m4b) podcasts
Follow-up to the audio-only feed guard. Real case: a Nodesignal-Talk podcast
published with `imeta m audio/mp4` + a `.f4a` URL. It was already filtered via
its imeta hint (audio/mp4 → hasAudio), but unlike the .ogg/.mp3 ones it actually
PLAYS in a <video> element (AAC-in-MP4 = audio + poster), which is why it looked
like it "worked." It's still a podcast, so it stays out of the video grid.

Closes the fallback gap for audio/mp4 notes that lack an imeta audio hint: added
.f4a/.m4b/.mka to AUDIO_ONLY_URL_PATTERN (AAC-in-MP4 audio / audiobooks /
Matroska audio). .mp4/.m4v remain video and are explicitly asserted not to match.

Tests: video-schema-and-conversion (+2, 13/13). Lint + build clean. TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:50:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 efdf497c59 fix(feed): filter cached audio-only notes at render + hydration, not just ingest
The convert-time guard (17b48cbf) only blocks FRESH events. Audio notes cached
before it existed are rehydrated straight from IndexedDB/localStorage as
already-converted video objects (`sig: "unavailable (cached)"`), bypassing
convertEventToVideo — so they kept showing in the main feed and channel grids.

Extract the audio check into a reusable object-level predicate
`isAudioOnlyVideoObject(video)` (url + magnet + imeta tags) and apply it at every
boundary a cached object can slip through:
  - convertEventToVideo now delegates to it (fresh ingest, unchanged behavior)
  - PersistenceManager.applyCachedPayload drops audio-only on hydration (purges
    the cache going forward)
  - NostrClient.getActiveVideos filters it (main feed, immediate effect)
  - nostrService.shouldIncludeVideo filters it above the viewer-is-author
    short-circuit (channel/author grids, incl. the creator's own channel)

Audio-only = no magnet AND no imeta `m video/*` AND (imeta `m audio/*` OR an
unambiguous audio url); `.ogg` alone stays video. Bumped client.js size baseline
(+6 lines). Tests: video-schema-and-conversion (+3 predicate scenarios, 11/11);
nostr-service + ingest-adapter suites green. Lint + build clean. TODO #60.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:36:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 17b48cbfd7 fix(feed): keep audio-only notes (podcasts/music) out of the video grid
A podcast/music note published as a native kind-30078 (content url = an .ogg/
.mp3 audio file, imeta `m audio/*`) was accepted by convertEventToVideo (it has
a url) and rendered as a broken <video> — black frame + audio on Chrome/FF,
MEDIA_ERR_SRC_NOT_SUPPORTED on Safari/iOS — since bitvid has no <audio> player.
The existing audio guard only covered the NIP-71 ingest path, not native events.

convertEventToVideo now marks such notes invalid ("audio-only source (no video)")
so every ingestion path that already drops invalid filters them: main feed buffer,
author/channel batch fetch, and local ingest. 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` alone is NOT filtered
(ambiguous → video/ogg); only when imeta says audio. A video imeta variant wins.
Direct open-by-nevent is intentionally unaffected — an explicit link still loads.

Near-term half of TODO #60 (the future dedicated Audio tab remains open).

Tests: tests/video-schema-and-conversion.test.mjs (+5, 8/8). Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:20:12 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1a4e53bdb1 docs(todo): add #60 — Audio tab (podcasts/music) + near-term audio-leak guard
Records the future Audio sidebar tab (dedicated <audio>/podcast player) plus the
near-term launch-hygiene fix to stop audio-only kind-30078 notes from rendering
as broken video in the grid. Captures the root cause (convertEventToVideo accepts
audio-only urls; .ogg maps to video/ogg; no <audio> fallback; the ingest-adapter
audio guard doesn't cover the native path) with the real example event, and
cross-refs #16 (live streams) / #17 (unified media grid).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 09:04:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 df06d84e93 docs(todo): add Design-Sync tracking list (bitvid → Claude Design port)
Records the incremental port of bitvid's token + component system into the
Claude Design project (projectId 6b3b1e2e-…): what it is, the DesignSync
workflow + card conventions (pixel-faithful, resolve @apply, token-linked,
both themes), Pass 1 done (5 Foundations + 2 Components cards synced), and the
queued expansion groups (menus/overlays, badges/pills, buttons/forms, modals,
larger surfaces) plus foundational gaps (red override accent, blur/breakpoint
tokens).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 08:46:26 -04:00
thePR0M3TH3ANandClaude Opus 4.8 311d44c8a7 docs(todo): de-stale pre-launch list — mark completed items, record #24 core
Audited the pre-launch TODO against the codebase. Several items were fully
implemented (with DONE/FIXED notes in their bodies and code verified present)
but their section headers never got a completion marker, making the list look
larger than it is. Updated headers for:
  #4 view-counter, #20 feed reliability, #41 native-dialogs, #45 tag
  suggestions, #46 post-publish refresh, #48 fluent profile switching,
  #25 per-event block list, #52 edit-refresh, #53 channel link, #54 like/
  dislike buttons, #34 NIP-71 mirror (dev done; live Amber VERIFY remains).

Recorded #24 (WoT per-author allow-override): core render-time override +
both UI entry points landed this session (a0131c71, 5844a36a); narrowed the
remaining open scope to the deferred NIP-51 sync + import follow-up.

No code changes — documentation only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 17:22:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c5bdd83183 test(#34): make my-videos mirror relay-truth test self-contained
The mirror flag helpers (nip71MirrorFlags) read the global `localStorage`,
but this test file only wired `globalThis.document` from JSDOM — never
`localStorage`. It therefore passed only when run inside the full suite,
relying on another test file leaking the localStorage shim into the shared
global. Run in isolation it failed 4/4 (isMirrorEnabled always returned
false → "Share to apps" instead of "Shared ✓").

Import the existing setup-localstorage shim so the file is honest in
isolation. No assertions changed — the product code (#34 relay-truth
detection) was already correct; this only removes a load-order dependency.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 17:05:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5844a36a54 Add "Always show creator" overlay button for account-level WoT override
Adds a second entry point (alongside the Safety-tab npub input) for the
per-author moderation override. On blurred/warned video surfaces, an
"Always show creator" button now sits beside "Show anyway". Clicking it
dispatches a `video:trust-author` document CustomEvent; the single app-level
listener in applicationBootstrap.js applies the account-level override and
refreshes affected videos, clearing the web-of-trust warning for every video
from that creator until reset.

Wired into VideoCard, VideoModal (persistent reused button + teardown
cleanup), and SimilarContentCard.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 17:01:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a0131c718e Safety: account-level (per-creator) web-of-trust overrides
Add "trusted creator" overrides that suppress the web-of-trust warning/blur
for ALL of a creator's videos until reset — the account-level companion to the
existing per-video "Show anyway".

- cache.js: per-author override store (bitvid:moderationAuthorOverrides:v1),
  keyed by author hex, with get/set/has/clear/list + persist/load, mirroring
  the per-video store.
- moderationDecorator: overrideActive now also honors an account-level override
  for the video's author, so the whole class of WoT warnings clears for every
  video by that creator.
- app.js: load the author overrides on startup.
- Services + contract: getAuthorModerationOverrides / setAuthorModerationOverride
  / clearAuthorModerationOverride; set/clear refresh the feed + fire the shared
  moderation-override event so the Safety tab list updates.
- Safety tab: a "Trusted creators" section — add by npub, list each with a
  Reset, mirroring the Active-overrides section.
- tests: storage set/get/has/clear/list, hex normalization, persist→load,
  invalid-input guard.

Next: the "Always show this creator" button on the video warning overlay
(second entry point) — a larger multi-component UI change, done separately.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 15:58:10 -04:00
thePR0M3TH3AN f2e35f619b Promote unstable → beta
Channel grid reliability (progressive render, reliable fetch, always-query defaults so nos.lol-only videos show); resolved-set durability; wallet restore popup; mobile polish (tour bottom-sheet, tab grid, account cards); admin ring/star everywhere.
2026-07-08 09:55:23 -04:00
thePR0M3TH3ANandClaude Opus 4.8 18e7197bdc Channel grid: remove the temporary [channel-load] diagnostics
The relay-union fix is confirmed working; drop the dev-only tracing added to
hunt it down.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 09:54:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8d331be1d5 Channel grid: always query bundled default relays (fix videos on nos.lol never showing)
Root cause of "a known video never shows on the channel": the video was a
whitelisted creator's NIP-71 note that lives ONLY on nos.lol, but the channel
fetch queried nostrClient.relays — the user's CAPPED NIP-65 read set — which
dropped nos.lol, so we never queried the one relay that had it. Confirmed by
per-relay probe: snort/damus/primal returned 0, nos.lol returned the 2 videos.

loadUserVideos now unions the user's relays with DEFAULT_RELAY_URLS (nos.lol,
damus, primal, snort) for the scoped channel fetch, so a creator's videos on
any bundled default relay are found regardless of the read cap. Channel pages
are a deliberate request for one creator, so the slightly wider fan-out is fine.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 09:38:22 -04:00
thePR0M3TH3ANandClaude Opus 4.8 31848a1e51 Channel grid: add [channel-load] diagnostics to trace where videos drop
Temporary dev-mode logging to pinpoint why a known video (e.g. a whitelisted
creator's NIP-71 video) never renders on their channel wall:
- loadUserVideos logs the relay set size, relay event count + kind breakdown,
  and the cache event count.
- buildRenderableChannelVideos logs per-stage counts: input → converted →
  uniqueById → byRoot → afterDeleted, plus drop counts for blacklist/canAccess
  and the final render count.

devLogger.info → visible in dev mode only (localhost), no prod noise.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 08:39:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 013b0344b9 Channel grid: progressive render + reliable fetch + no premature "No videos"
The channel video grid felt slow and often flashed "No videos" because, when
the warm cache was cold (direct URL, a new tab, or an author not in your feed),
it fell back to a bespoke one-shot pool.list per relay with an 8s timeout,
AWAITED ALL relays before rendering once, and showed "No videos" whenever that
came back empty (which a timed-out/flaky relay does). "My Videos" is fast
because it reads the warm shared cache and only refetches on demand.

Rework loadUserVideos:
- Progressive render: paint the grid as each relay/source returns (deduped,
  coalesced, serialized re-render) instead of blocking on the slowest relay.
- Reliable fetch: also route through nostrService.fetchVideosByAuthors — the
  same proven path My Videos uses — which streams into the shared cache, so
  this grid, its revisits, and every other grid benefit.
- No premature empty: mid-flight renders never pass allowEmptyMessage; the
  "No videos" message is shown only on the final render, after every source
  has genuinely settled. The cache-first paint is preserved.
- check-file-size: bump channelProfile grandfather for the growth.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 08:23:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 27808533d2 Mobile: same stacked treatment for the Admin + Moderation account cards
Extends the .profile-account-card pattern to the remaining compact account
lists so they all behave consistently on phones:

- Admin lists (whitelist/blacklist/editors): use the shared
  .profile-account-card / __actions classes (was bespoke responsive utilities;
  now full-width equal-width action buttons on mobile like the others).
- Moderation overrides: the summary+actions row (a nested flex inside an
  already-padded card) uses a new padding-less .profile-account-card__row
  variant so it stacks on phones too.

The account switcher (single tap-button with a column meta-stack) and DM
conversation rows (tap-to-open, no action buttons) are already mobile-friendly
and structurally different, so they're intentionally left as-is.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 07:35:05 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9ae49bf51f Mobile: stack the compact creator/account cards (Subscriptions, Friends, Blocked)
These cards were `flex justify-between` — three action buttons squeezed the
name/npub into a tiny column, so the (break-all) npub wrapped to 4 lines and
the whole card looked crammed on phones.

- Shared .profile-account-card + .profile-account-card__actions classes on the
  Subscriptions, Friends and Blocked cards (was raw utility soup).
- Desktop keeps the row layout (summary left, actions right).
- Phones (<sm) stack it: summary on top at full width (name truncates on one
  line, npub fits on one line), actions as a full-width row of equal-width
  buttons with tighter padding below.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 07:27:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c72c74d15b Mobile profile tab grid: uniform card tiles (fix ellipse/uneven look)
The mobile tab grid reshaped btn-ghost pills, so tiles were rounded-full
ellipses that turned into circles once a label wrapped, and a single 2-line
label ("Admin (Moderation)") stretched its whole row taller than the rest.

- Uniform tile height via grid-auto-rows (a 2-line label can no longer
  stretch its row); token renamed to --profile-modal-nav-tile-height (5rem).
- Consistent card radius (--radius-xl) instead of rounded-full.
- Subtle --color-panel fill so tiles read as solid cards, not floating
  outlines (the active tile's bg-primary keeps winning on specificity).
- overflow:hidden + block-size:100% so content fills each equal tile cleanly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 07:16:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4f56a2bd26 Wallet restore popup: re-offer on login when missing locally (like storage)
The wallet already rode the same login "restore from Nostr" offer as storage,
but a once-per-device "offered" flag suppressed it — so on a device without
your wallet you fell back to the manual Sync button. Now:

- encryptedSyncItem: add hasLocal(pubkey) (non-null payload => item present
  locally, e.g. a connected wallet / configured storage).
- settingsRestorePrompt: re-offer an item whenever it's MISSING locally and a
  Nostr backup exists — bypassing the "offered once" flag — so it surfaces
  exactly when you'd want it. Only respect the once-per-device flag when the
  item IS present locally (no nagging). A new per-device "dismissed" flag,
  set when you decline, keeps it quiet after an explicit "no".
- Accepting enables sync, so it auto-pulls silently thereafter (the "both"
  behavior: silent auto-pull when enabled + popup when missing & off).
- Unknown services (no hasLocal) keep the legacy behavior.
- tests: re-offer when missing despite prior offer; no re-offer when present;
  decline stays quiet.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 06:56:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c562d9ee65 Hold the settings-restore prompt until the onboarding tour is done
The "restore storage/wallet from Nostr" confirm was popping over the guided
tour (both fire on login). Gate the restore dialog behind a "tour gate":

- maybeOfferOnboardingTour opens the gate synchronously when the tour will
  run (shouldOfferOnboarding), before its own start delay — so ordering can't
  race. The tour offer is now called before the restore offer on login.
- launchBitvidTour gains an onEnd(status) hook (fires on finish AND skip);
  startOnboardingTour closes the gate on end, and also closes it if the tour
  never starts (logged out, no eligible steps) so restore can't hang.
- The restore prompt's confirm now awaits whenOnboardingTourInactive() before
  showing — the remote existence check still runs in parallel, only the dialog
  is held back. No tour → gate stays closed → restore behaves as before.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 06:45:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0e2f81d222 Mobile: tour bottom-sheet + measured sizing; tidy profile tab-grid tiles
Onboarding tour (tourEngine.js):
- On narrow viewports (<640px) dock the popover as a fixed BOTTOM SHEET
  instead of anchoring it — kills the off-screen/jump-to-center glitches
  caused by short viewports, wrapping buttons and URL-bar resize. The
  spotlight still highlights the target, scrolled to center so it sits above
  the sheet.
- On desktop, measure the ACTUAL popover size (getBoundingClientRect) and
  feed it to computeTourPlacement, instead of the fixed 240/336 guesses that
  pushed tall popovers off-screen.

Profile modal mobile tab grid (CSS):
- Equal-height tiles via a min-height token + centered content, so long
  labels no longer leave a ragged grid.
- Move the unread dot to the tile's top-right corner (was landing in the
  grid gutter at the vertical-nav left edge).
- Tighter line-height/padding + non-shrinking glyph.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 06:34:44 -04:00
thePR0M3TH3AN 564f36f41f Promote unstable → beta
Resolved-set durability (local cache + mirror + loss-proof republish + whitelist fallback); nav unread-dot position fix.
2026-07-07 22:13:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 55db677637 Submissions: make the resolved-set durable so handled items stay handled
Resolved submissions were resurfacing (most visibly after a cache clear)
because the admin resolved-set wasn't durable: it was never mirrored (relays
pruned it) and the read-modify-write could republish a SHRUNK replaceable
list when a cold-load read missed the current set.

- Device-local resolved-id cache (localStorage): grows only, backs up the
  relay list. Hides handled items even if a relay read momentarily misses,
  and makes markSubmissionResolved loss-proof — it folds every locally-known
  resolved id into each republish, so a missed read can't drop entries.
- Mirror the resolved-set on each fetch (re-broadcast) so relays keep it
  alive, same as we do for pending submissions.
- Persist relay-known resolved ids into the local cache on fetch.
- Belt-and-suspenders: an application whose applicant is already whitelisted
  is treated as handled even if the resolved-set were lost entirely
  (callers now pass accessControl.getWhitelist()).
- tests: whitelist-hide, local-hide after a cold read, and loss-proof
  republish (earlier id carried forward despite a missed read).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 17:02:20 -04:00
thePR0M3TH3AN bc8f73f9cc Promote unstable → beta
Batch: submissions admin section, playlists #37, zaps #47, admin star/ring, appeals author-level unblock, notification dots, playlist clobber fix.
2026-07-07 16:08:48 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6134a6bb78 Fix profile nav unread dots anchoring to the nav column instead of the button
The .profile-nav__unread-dot is absolutely positioned (top:50%, left:-6px),
but the nav buttons weren't a positioning context (.btn/.focus-ring set no
position), so the Admin (and latent Messages) dot resolved against the whole
nav column — showing mid-height at the far left instead of on its button.
Make .profile-modal__nav > button position:relative so each dot anchors to
its own button.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 16:00:44 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5eb7545a3f Submissions persistence: publish to nos.lol + admin re-broadcast mirror
Submissions are signed by throwaway ephemeral keys and public relays prune
events from pubkeys with no social graph, so the queue quietly evaporated.

- Forms: also publish to nos.lol (better retention, already in the app's
  query set) alongside snort/damus/primal.
- submissionFacade: on each fetch, re-broadcast the still-pending signed
  events back to our relays (publishEventToRelays) so pruning doesn't drop
  them. Best-effort, fire-and-forget — never blocks or fails the fetch;
  no-ops when no relay pool is available.
- test: fetch still returns the pending queue when a pool is present.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 15:48:48 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d091ce8bac Admin ring on comments/channel + pending-submissions dots on Admin tab & Submissions subtab
- adminBadge.js: add applyAdminRing(el, id) for surfaces that already place the
  star on a specific element and just need the ring.
- comments: ring + star on admin authors' avatars; round the comment avatar box
  so the ring reads as a circle.
- channel page: ring the inner rounded avatar box (star already on the wrap).
- Notification dots: red dot on the profile modal's Admin nav tab and on the
  Submissions subtab button when there are pending submissions, so admins know
  where to look. profileModalController.setSubmissionsUnreadIndicator toggles
  both (Admin nav dot gated on the tab being visible); driven from
  applyProfileNotificationDot off app._submissionsPending.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 14:59:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 37edbd18d2 Admin ring + star across avatars (identity, switcher, cards, player, DMs)
Add a reusable decorateAdminAvatar(avatarEl, id) that applies BOTH the gold
ring (box-shadow, not clipped by the avatar's overflow) and the corner star
(hung off a non-clipped .admin-avatar-wrap it ensures around the avatar).
Idempotent; works on attached or freshly-built detached avatars.

Wired onto:
- profile modal "Acting as" identity pill
- account-switcher saved-profile avatars
- video card creator avatars
- video player creator avatar
- DM conversation list + message bubbles (upgraded from star-only to ring+star)

adminBadge.js: split the star decision from its DOM (setAdminStar) so the
ring and star share one isAdminActor check; added ADMIN_RING_CLASS /
ADMIN_WRAP_CLASS. tests: decorateAdminAvatar (wrap/ring/star, idempotency,
non-admin, detached).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 14:24:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fdddbc2bb4 Profile button: gold admin ring + fire on session restore
Make the super-admin/admin signal unmistakable on your own profile button:
a gold ring around the avatar (with a thin page-colored gap so it reads as
a ring, not a border) in addition to the corner star.

- applicationBootstrap.js: refreshAdminStar toggles an is-admin class on the
  profile button using applyAdminStar's return value.
- CSS: #profileButton.is-admin > div draws the gold ring (box-shadow, not
  clipped by the avatar's overflow).
- uiCoordinator.js: refresh the admin star/ring inside applyAuthenticatedUiState
  so it also lights on session restore, not only a fresh login — the likely
  reason it wasn't showing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 11:43:28 -04:00
thePR0M3TH3ANandClaude Opus 4.8 60157eb743 Appeals: diagnose and handle author-level blocks too
Approving a content appeal now checks WHY the video is hidden and applies
the right fix, instead of only ever touching the per-event block list.

- submissionFacade.js: resolveEventAuthorHex({ eventId }) looks up the event
  and returns its author hex — the authoritative author, so we don't rely on
  the appellant to supply it.
- submissionsSection.js approveAppeal: branch on the real block state.
  * Author-level block (whole npub) → its own strongly-worded confirmation
    ("restores ALL their content"); on confirm, removeFromBlacklist plus any
    event block. Never bundled into the default unblock.
  * Event-level block only → unblock this video (as before).
  * Neither → distinguish "nothing to unblock" from "couldn't verify on
    relays" and just mark the appeal resolved.
- tests: resolveEventAuthorHex (author normalization, blank/not-found).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 10:32:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9ca726bc99 Admin star on DM message bubbles from admin peers
Extend the admin star to per-message avatars: incoming bubbles from an
admin peer now show the gold star. MessageBubble gains an opt-in adminId
and wraps its avatar img in a non-clipped shell only when the author is an
admin (non-admin bubbles keep the bare <img>). MessageThread passes the
peer pubkey for incoming bubbles; outgoing bubbles are the current user,
whose admin status is already shown on the profile button.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 10:24:53 -04:00
thePR0M3TH3ANandClaude Opus 4.8 610935ac03 Admin star badge on admin avatars across the app
Show a small gold "verified admin" star in the corner opposite the red
notification dot whenever a rendered profile belongs to a bitvid admin
(the super admin or a current list editor).

- adminBadge.js (new): isAdminActor(hex|npub) sync check against
  accessControl, and applyAdminStar(container, id) which adds/removes a
  single `.admin-star` in a non-clipped, positioned container. Idempotent.
- CSS: `.admin-star` (top-left gold disc, mirrors the top-right dot),
  `.dm-avatar-wrap`, and position:relative on the comment avatar wrapper.
- Profile button: app.refreshAdminStar() lights the logged-in user's star;
  re-runs on login/logout and on accessControl.onEditorsChange (so a
  late-loading editor list still resolves).
- Comments: star on comment-author avatars (commentNodeFactory).
- Channel page: wrap the clipped avatar and star the channel owner.
- DMs: Avatar gains an opt-in adminId — only admin avatars get wrapped so
  non-admin rows are byte-identical; ContactRow passes the peer pubkey.
- tests: admin-badge unit coverage (recognition, idempotency, removal).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 10:10:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2803f6b56f Submissions: labeled actions + real appeal-driven unblock
Make each admin action self-explanatory and give content appeals a real
effect instead of a dead-end "Dismiss".

- submissionsSection.js: per-type action sets, each button paired with a
  one-line description. Applications show Approve (adds to whitelist) / Deny;
  appeals that name a real event show "Approve & unblock" (removes the video
  from the event block list) / "Keep blocked"; other types show "Mark handled".
- submissionService.js: parse an appeal's `e` tag into targetEventId (trusted
  only when it's a well-formed 64-char hex id).
- iframe-content-appeals-form.html: add a required "Blocked video link or
  event ID" field; regex-validate + normalize a share link / nevent / note /
  hex to the hex event id, tag it as `e`, and include it in the body. Block
  submission when no valid reference is provided.
- CSS: stacked action + hint styling; iframe-form .form-hint.
- tests: cover targetEventId parsing (valid, malformed, absent).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 09:52:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ef4191538c Profile dot: show for DMs OR pending admin submissions
The profile-button red dot now lights when the logged-in user has either
unseen DMs or (if they can edit admin lists) pending submissions to review.
The Messages-nav dot stays DM-only.

- applicationBootstrap.js: split indicator state into _dmUnread and
  _submissionsPending; applyProfileNotificationDot() ORs them for the
  profile dot. Added refreshSubmissionsIndicator (gated on FEATURE_SUBMISSIONS
  + canEditAdminLists), a bitvid:submissions-changed listener, a 180s poll,
  and a startup refresh.
- authSessionCoordinator.js: refresh the submissions indicator on
  auth-login and auth-logout alongside the DM refresh.
- submissionsSection.js: emit bitvid:submissions-changed after approve/deny
  so the dot recomputes for all admins.
- check-file-size.mjs: bump authSessionCoordinator grandfather baseline.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 09:35:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 36cb71b676 submissions(#23): format submission bodies (safe markdown-lite)
Submission cards showed the raw form markdown ("### Bug Report", "- npub: …").
Added a tiny, SAFE renderer: `#…###` headings → bold lines, leading `- `/`* `
bullet markers stripped, `**bold**` honored inline — built entirely with
textContent/createTextNode + <strong>, so no HTML is ever parsed from the
content (submissions are untrusted public events; rendering raw markdown/HTML
would be an XSS hole). Lint (innerHTML check) + build clean; tests 26/26.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 09:21:26 -04:00
thePR0M3TH3ANandClaude Opus 4.8 ba287a62be submissions(#23): forms tag ADMIN_SUPER_NPUB (fix admin tab showing nothing)
The forms hardcoded the maintainer's PUBLIC contact npub (npub13yarr7j… — the
blog/tip-jar/nostr persona, the old DM recipient) as the submission recipient,
but the admin Submissions tab queries submissions tagged to ADMIN_SUPER_NPUB
(npub15jnttpym…). Mismatch → the tab found nothing. Point all 5 forms' recipient
at ADMIN_SUPER_NPUB so submissions reach the admin whose tab lists them.

Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 09:14:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7db70330dc playlists(#37): fix playlist grid clobbered by the disclaimer-dismissed feed reload
On a direct #view=playlist load, dismissing the first-visit disclaimer fired
`application.loadVideos(true)` — a forced RECENT-feed load into the shared
#videoList — which raced the playlist view's own fetch and replaced the playlist
with recent videos. It only reproduced when the disclaimer showed, which is why
it was intermittent ("sometimes"), and always after the playlist had already
rendered ("loads then gets dropped").

The disclaimer-dismissed reload is now view-aware: for a playlist view it re-runs
the playlist view's init (viewInitRegistry.playlist) so its grid re-renders,
instead of force-loading the recent feed. Feed views keep the loadVideos reload.

(A fuller fix would give the playlist view its own grid, decoupled from the feed
system's #videoList — noted as a follow-up.)

Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 21:44:54 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7cbd77110d submissions(#23): coerce getEditors() (Set) before mapping in the submissions pane
With FEATURE_SUBMISSIONS on, refreshAdminPaneState fires populateSubmissions,
which mapped accessControl.getEditors() directly — but that returns a Set, so
`.map` threw ("map is not a function") as a leaked async rejection after the
profile-modal test ended. Coerce with Array.from() (handles Set or array).
profile-modal-controller tests 26/26.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 21:14:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8eddb11a03 submissions(#23): enable FEATURE_SUBMISSIONS
Turns on the admin Submissions tab now that the loop is complete (schema +
facade + admin approve/deny + forms). The forms already publish kind-30083
unconditionally, so enabling the tab is what makes the new intake coherent —
submissions are now visible + actionable instead of landing with no admin UI.
Rollback: set false to hide the tab (forms keep publishing 30083).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 21:06:52 -04:00
thePR0M3TH3ANandClaude Opus 4.8 878f01b819 submissions(#23): forms publish structured kind-30083 instead of kind-4 DMs
All 5 iframe forms now publish a PUBLIC, structured bitvid submission (kind 30083)
from their ephemeral key instead of a NIP-04 encrypted kind-4 DM — so the admin
Submissions tab can list + approve/deny them.

- application → k=application (+ applicant = the required npub)
- bug-fix → k=bug, feedback → k=feedback, request → k=feature
  (each includes applicant only when the optional npub is filled in)
- content-appeals → k=appeal (no npub field)
Each tags ['d', <id>], ['k', <type>], ['p', <admin hex>], ['t','bitvid-submission'],
['client','bitvid']; the form body goes in plaintext content. The post-publish
verification subscription now watches kind 30083. No encryption anywhere.

Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 20:44:34 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2a21756842 submissions(#23): admin "Submissions" sub-tab (approve/deny)
The headline: an admin surface to triage the structured submissions.
- profile-modal.html: "Submissions" sub-tab button + #adminSubmissionsSection
  (refresh + loading/empty + list), alongside Whitelist/Blacklist/Blocked
  videos/Moderators.
- ProfileAdminController: adds "submissions" to the sub-tab machinery (button +
  section maps, selectAdminSubtab dispatch, refreshAdminPaneState populate), gates
  the tab on FEATURE_SUBMISSIONS, and wires the list/refresh listeners.
- js/ui/profileModal/submissionsSection.js (extracted to keep the controller under
  the 1200-line cap, like blockedVideosSection.js): populate/render + approve/deny.
  Approve (applications only) → accessControl.addToWhitelist(actor, applicant) +
  markSubmissionResolved(approved) + refresh the whitelist; Deny/Dismiss →
  markSubmissionResolved(denied). Optimistic remove; re-sync on failure. Cards show
  the type badge + claimed applicant + body.
- tailwind.source.css: .submission-card* (token-only).

Gated by FEATURE_SUBMISSIONS (tab hidden when off). Lint + build clean;
profile-modal-controller tests 26/26.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 20:40:06 -04:00
thePR0M3TH3ANandClaude Opus 4.8 28b23e0078 submissions(#23): fetch-pending + resolved-set facade
Network layer for the admin Submissions tab. Because submissions are authored by
an ephemeral key, admins can't NIP-09-delete them (relays only honor same-author
deletions), so "handled" state lives in an admin-authored RESOLVED-SET.

- fetchPendingSubmissions({ adminHex, editorHexes }) — lists kind-30083 to the
  admin, subtracts the UNION of every editor's resolved-set (so any moderator's
  action clears it for all), dedupes by claimed applicant (newest wins).
- markSubmissionResolved({ submission, status, actingHex }) — read-modify-write
  the acting moderator's resolved-set (kind-30000, d=bitvid:admin:submissions-
  resolved), appending the submission's event id + status + applicant, republished
  via the active signer.

tests/submission-facade.test.mjs (4): pending excludes resolved + dedupes by
applicant (newest); blank-admin/no-relay guards; resolved-set append keeps prior
entries and records status+applicant; no-op guards. Mock client. 4/4; lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 20:20:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a18230eb70 submissions(#23): foundation — kind-30083 schema, builder, service + tests
First slice of structured submissions (replacing the kind-4 encrypted DM forms).
Pure, hermetic core — no UI or network yet.

- nostrEventSchemas.js: SUBMISSION_KIND=30083 (+ NOTE_TYPES.SUBMISSION, BASE_SCHEMAS
  entry) and buildSubmissionEvent(). PUBLIC + addressable; data in tags: d (id),
  k (type), applicant (npub), p (admin), t=bitvid-submission; free-text in content.
- js/submissions/submissionService.js: SUBMISSION_TYPES + labels, parseSubmissionEvent,
  buildSubmission, generateSubmissionId. Pure — unit-testable with no relays/signer.
- constants.js: FEATURE_SUBMISSIONS (default OFF until the admin UI + forms ship).
- tests/submission-service.test.mjs: 8 scenarios (build tags/type/default, d-tag
  required, parse structure/reject/unknown-type→other, build→parse round-trip of
  every type, id generation).
- check-file-size.mjs: bump nostrEventSchemas grandfather 3250→3541 (adding a new
  event type to the schema registry is the prescribed pattern).
- docs/nostr-event-schemas.md: catalogue row.

Schema validation ✨, lint, build all pass; tests 8/8.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 20:17:47 -04:00
thePR0M3TH3ANandClaude Opus 4.8 87e31d9853 playlists(#37): drag-and-drop reorder in the Playlists management tab
Videos within a playlist can now be dragged to reorder (desktop), on top of the
↑/↓ buttons kept for touch/accessibility. HTML5 DnD delegated on the list:
dragstart records the row's playlist + index, dragover highlights the drop
target (accent inset bar) and allows the drop, drop reorders via
reorderPlaylistItems and publishes optimistically. Drag is scoped to a single
playlist (can't cross playlists); dragging row dims to 0.5; grab/grabbing cursor.

Lint + build clean; profile-modal-controller tests 26/26.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 15:48:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 791296266a playlists(#37): playlist view resolves from cache first (no needless relay fetch)
The view always re-fetched the creator's videos from relays, so arriving from
the channel (where they're already loaded) or re-visiting a playlist lagged on a
round-trip. Now it resolves from the active cache first and only fetches when
some playlist coordinates are still missing (a cold deep-link). Channel→playlist
navigation is instant; deep-links still fetch just what they need.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 15:48:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 73a99358a2 playlists(#37): profile-modal "Playlists" tab — rename / delete / reorder / remove
A central management surface, a sibling tab to "My Videos" (gated by
FEATURE_PLAYLISTS — hidden when off).

- components/profile-modal.html: "Playlists" nav item + #profilePanePlaylists.
- js/ui/profileModal/ProfilePlaylistsController.js: lists the user's playlists
  (incl. empty) with a collapsible row each — Rename, Delete, and an expand to a
  video sublist with ↑/↓ reorder and Remove. Fetches the referenced creators'
  videos so the sublist shows real titles/thumbnails. Edits are optimistic and
  re-sync on failure; publishes via the facade; dispatches
  bitvid:playlists-changed so the channel section + this pane stay in sync.
- profileModalController.js: instantiate + bind nav/pane + cache DOM + register
  listeners + selectPane("playlists") refresh; hide the tab when the flag is off.
- promptDialog.js: showTextPrompt (plain text prompt) for rename.
- playlistFacade.js: deletePlaylist — NIP-09 kind-5 (a + e refs) plus an empty
  republish so kind-5-ignoring readers also drop it.
- tailwind.source.css: .playlist-row* styles (token-only).

Lint + build clean; profile-modal-controller tests 26/26.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 15:16:06 -04:00
thePR0M3TH3ANandClaude Opus 4.8 2a543c3ba9 playlists(#37): playlist view fetches its videos (fix deep-link showing recent feed)
On a DIRECT load of a playlist URL the video cache is empty, so the view
resolved zero videos and the shared #videoList kept showing the default recent
feed. The view now fetches the referenced creators' videos from relays
(nostrService.fetchVideosByAuthors) before resolving, so a cold/deep-linked
playlist renders its ACTUAL videos in order. Resolution runs against the fetched
pool + the active cache; the grid is always rendered (even empty) so it can't
fall through to the recent feed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 14:48:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f46b1ce7f3 playlists(#37): restyle playlist cards (stacked cover + themed scrim) + back button
Design polish from feedback:
- Playlist cards are now a "stack of clips" cover: the first video's thumbnail
  fills a 16:9 tile with two offset/rotated layers peeking behind it (::before/
  ::after). A themed gradient scrim (accent-strong→accent→clear, via color-mix)
  darkens the base so the title sits BOLD and WHITE over the cover instead of
  small grey text underneath. Video count moved to a pill in the top-right.
  channelPlaylists.js resolves the cover from the app's active videos
  (buildVideoAddressPointer → video.thumbnail), falling back to playlist.image.
- Back button on the playlist view: was a bare "← Back to channel" text link, now
  a proper .btn-ghost (sm) pill with a chevron icon — matches the site's buttons
  and is theme-aware.
- tokens.css: added --size-4px/--size-5px for the stack offsets (token lint).

Scrim/title use --color-white (always light — the scrim is always dark
regardless of theme). Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 14:18:31 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a2e26b00b9 playlists(#37): close the picker instantly + auto-refresh the channel section
Fixes two rough edges from first use:
- The "Add to playlist" dialog blocked on the relay publish before closing, so it
  felt stuck / "didn't close, just reset". Now it closes IMMEDIATELY on Save and
  publishes in the background; the result comes back as a toast (optimistic, like
  the feed insert). No more waiting on the relay round-trip with the modal open.
- After adding/creating a playlist you had to hard-refresh to see it. The picker
  now dispatches `bitvid:playlists-changed`, and channelPlaylists.js listens for
  it (matching the current channel) and re-fetches — immediately and again after
  ~2s to cover relay propagation lag — so the section updates on its own.

Syntax + lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 14:09:58 -04:00
thePR0M3TH3ANandClaude Opus 4.8 f4fe4982bd playlists(#37): "Add to playlist" create/edit flow + enable FEATURE_PLAYLISTS
The create path — makes playlists actually usable end-to-end.
- js/ui/playlistPicker.js: an "Add to playlist" dialog (modeled on confirmDialog)
  listing the signed-in user's playlists as checkboxes (checked when the video is
  already in them) + a "Create new playlist" input. On Save it diffs the
  checkboxes and publishes only changed playlists (add/remove the video) plus any
  new one, via the facade (active signer). Reads the current pubkey from
  nostrClient, so callers pass only the video coordinate.
- videoMenuRenderers.js: "Add to playlist" item in the video ⋯ menu, gated by a
  new canManagePlaylists param.
- moreMenuController.js: computes canManagePlaylists (FEATURE_PLAYLISTS && logged
  in), threads it into the panel context, and adds the add-to-playlist handler
  (resolves the video → coordinate via buildVideoAddressPointer, closes the
  popover, opens the picker; success/error routed to the app's toasts).
- constants.js: FEATURE_PLAYLISTS flipped ON now that the core loop (create /
  add / remove / channel section / playlist view) works. Rollback: set false.

Owner controls on the playlist view (rename / drag-reorder / hard-delete) are a
follow-up; removing every video empties a playlist (drops from the listing).
Syntax + lint + build clean; more-menu-controller tests 9/9.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 13:57:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 291d55d966 playlists(#37): playlist view — renders a playlist's ordered videos
New route #view=playlist&pubkey=<hex>&id=<playlist id>:
- views/playlist.html: title + meta + the shared #videoList grid.
- js/playlistView.js: initPlaylistView() reads pubkey+id from the hash, fetches
  the playlist (facade), resolves its ordered `a` refs against the app's active
  videos (buildVideoAddressPointer keys, same 30078 contract), points the back
  link at the creator's channel, and renders via app.renderVideoList with
  preserveOrder:true so the playlist's order is kept (not re-sorted by date —
  #21). Unresolved refs are skipped; the meta line shows "N of M available".
  Gated by FEATURE_PLAYLISTS.
- FEED_TYPES.PLAYLIST + viewManager registration (getApp: getApplication).

The channel-section cards now resolve to a working view. node --check + lint +
build clean. End-to-end browser verification comes with the create/edit slice
(needs a way to actually make a playlist).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 13:16:28 -04:00
thePR0M3TH3ANandClaude Opus 4.8 85903965a4 playlists(#37): FEATURE_PLAYLISTS flag + channel "Playlists" section
- constants.js: FEATURE_PLAYLISTS (default OFF) so nothing is user-visible until
  the whole playlist UI ships; flip to preview on unstable.
- js/channelPlaylists.js: wireChannelPlaylists(pubkey) fetches the creator's
  playlists (facade) and renders a responsive row of cards (thumbnail + video
  count + title) linking to `#view=playlist&pubkey=…&id=…`. Hidden when the flag
  is off, no playlists, or the container is missing; a nav-token guards against a
  stale fetch resolving after a channel switch. DOM built with textContent (no
  innerHTML). Kept out of the 5729-line-capped channelProfile.js.
- channelProfile.js: one-line wireChannelPlaylists(currentChannelHex) hook next
  to wireChannelZapTotal (no-op while the flag is off).
- views/channel-profile.html: #channelPlaylistsSection above the video grid.
- tailwind.source.css: .playlist-grid / .playlist-card* (token-only; responsive
  via @apply like .bv-grid-video).

Lint + build clean. The playlist view the cards link to lands next.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 13:12:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 0caf468c36 playlists(#37): fetch/publish facade over the live client
Network wiring on top of the pure playlistService core:
- fetchCreatorPlaylists(pubkey) — lists kind-30082 by author via the L1
  SubscriptionManager, collapses to newest-per-`d` (NIP-33 replaceable), drops
  empty playlists from the listing (a cleared playlist republishes empty),
  sorts newest-first.
- fetchPlaylist(pubkey, id) — newest matching event, parsed.
- publishPlaylist(playlist) — build + sign + publish via
  nostrClient.signAndPublishEvent (active signer), replacing the same `d`.
All entry points take an injectable client for hermetic testing.

tests/playlist-facade.test.mjs (5): newest-per-id + empty/other-author drop +
sort, includeEmpty, blank/no-relay guards, fetch-one newest, publish emits a
kind-30082 event with the right d/title/ordered a-refs under the "playlist"
context. Mock client; no relays/signer. Lint clean; 5/5.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 12:51:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 e5064eeb87 playlists(#37): foundation — kind-30082 schema, builder, pure service + tests
First slice of creator playlists. Adds a bitvid-native addressable playlist
event and the pure, hermetic core that builds/parses/mutates it. No UI or network
yet — those land in follow-up commits.

- nostrEventSchemas.js: PLAYLIST_KIND=30082 (+ NOTE_TYPES.PLAYLIST, BASE_SCHEMAS
  entry) and buildPlaylistEvent(). Data lives in tags: d (id), title/description/
  image, and ordered `a` refs (30078:pubkey:d) to videos; order = tag order;
  repeated refs deduped. Deliberately NOT NIP-51's 30005 "video curation set" —
  bitvid already reads 30005 as legacy interest-sets, so reuse would collide.
- js/playlists/playlistService.js: parsePlaylistEvent, buildPlaylist, and
  immutable add/remove/reorder item helpers + videoCoordinate() (reuses the
  canonical buildVideoAddressPointer, same 30078 contract as the zap fix) +
  generatePlaylistId(). Pure — fully unit-testable with no relays/signer.
- tests/playlist-service.test.mjs: 12 scenarios (build tags + order + dedup,
  d-tag required, parse structure/rejects/defaults, build→parse round-trip,
  add/remove/reorder immutability + clamping, coordinate derivation incl. the
  kind-0 internal-object case).
- docs/nostr-event-schemas.md: catalogue row for the new kind.

Schema validation, lint, and build all pass; tests 12/12.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 12:48:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a36d57c8b4 tests(11): spec-correct searchFilters author parsing (validated pubkeys)
tests/unit/searchFilters.test.mjs fed author:abc123 and expected
authorPubkeys === ["abc123"] with zero errors. The author filter now runs values
through normalizeAuthorValues (searchFilters.js): it accepts a 64-char hex pubkey
or a decodable npub1 and DROPS anything else (recording an error), because videos
carry HEX pubkeys and npub authors otherwise matched nothing. "abc123" is neither,
so it is correctly dropped → authorPubkeys empty + an error, contradicting this
happy-path test. Equally-strict spec_correction: use a valid 64-hex author so the
test exercises the intended normalization. Verified: # pass 4 # fail 0.

This was the LAST failure in a full per-file scan of all 392 test files (the
fail-fast runner had been surfacing them one at a time).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:40:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bd7de888e0 tests(11): spec-correct hashChangeHandler default view (trending, not most-recent)
tests/unit/hashChangeHandler.test.mjs asserted the logged-out default view was
views/most-recent-videos.html. Commit 04621efc (2026-06-24, "Trending defaults")
made TRENDING the deliberate logged-out landing (FEATURE_TRENDING_FEED defaults
on; hashChangeHandler.js:95-99 → logged out = TRENDING, else RECENT). This is the
SAME stale spec the sibling tests/app/hash-change-handler.test.mjs was already
corrected for — this second file was missed. Equally-strict spec_correction:
assert views/trending.html. Verified: # pass 4 # fail 0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:36:15 -04:00
thePR0M3TH3ANandClaude Opus 4.8 567a12c758 tests(11): make uploadModal-reset zombie-callback test deterministic (correct mock boundary)
The "guard against zombie callbacks" subtest passed its asserts but leaked async
activity after the test ended (node:test flags that as failure). Root cause: it
mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but
handleVideoSelection drives the upload through this.mediaUploader.uploadVideo —
a DIFFERENT object with its own helpers — so the mediaUploader still ran real
createTorrentMetadata()/hashing on a synthetic {name:"video.mp4"} file ("invalid
input type" async) and captureVideoMetadata() ran URL.createObjectURL(file)
("must be an instance of Blob"). Not intermittent — a consistent wrong-boundary mock.

Fix: mock at the real DI boundary — modal.mediaUploader.uploadVideo = () =>
delayedUpload (controllable pending promise) — and stub captureVideoMetadata.
Hermetic, deterministic (3/3 exit 0, # pass 3). Mutation-verified: breaking the
videoUploadId zombie guard makes the test fail. Production code untouched. See
TEST_INTEGRITY.md.

NOTE: the runner is fail-fast, so the full suite surfaces pre-existing failures one
at a time. tests/unit/hashChangeHandler.test.mjs is next (consistent AssertionError
on "loads default view if hash is empty" — likely the same stale-default bug the
sibling tests/app/hash-change-handler.test.mjs already had when TRENDING became the
logged-out default). Enumerating full scope next.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:29:36 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a326d04330 tests(11b): un-quarantine dm-block-filter — hermetic block-list subscription; QUARANTINE now empty
Fully closes #11b's un-quarantine work: the QUARANTINE map is now EMPTY.

dm-block-filter.test.mjs (node:test) PASSED all subtests then hung, so the
node:test runner never exited. Diagnosed via process.getActiveResourcesInfo():
the "integration with loaded user block list" test's manager.loadBlocks() opens a
LIVE block-list subscription through nostrClient's real SimplePool aimed at the
test's fake relay URLs, leaking 4 TCP sockets + reconnect timers. It is NOT the
decrypt-retry timer (verified absent). Nulling nostrClient.pool did not help —
loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real
pool when pool is falsy.

Fix (node:test-SAFE, no force-exit): make the test hermetic — (1) a TRUTHY stub
pool so ensurePool() is skipped, (2) a no-op getSubscriptionManager so
startBlockListSubscription opens nothing. The block-merge logic under test still
runs fully through the already-stubbed fetchListIncrementally. Restored in finally.
Verified BOTH directions: a clean run exits 0 (# pass 12), AND a deliberately
broken assertion still exits 1 (# fail 1) — failures are NOT masked. A force
process.exit in an after() hook was rejected precisely because it masked failures
(process.exitCode isn't set when the hook fires). See TEST_INTEGRITY.md.

NOTE: the full suite still has one SEPARATE, pre-existing red unrelated to #11b —
tests/ui/uploadModal-reset.test.mjs (TODO #11): its "zombie callbacks" subtest
feeds a plain object where webtorrent expects a Blob ("must be an instance of
Blob") and leaks async activity after the test ends. Untouched here; tracked
under #11 (harden flaky tests), not the #11b quarantine work.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 11:03:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d6ac9429c1 tests(11b): un-quarantine user-blocks + nwc-client + nostr-publish-rejection; quarantine dm-block-filter
Closes the #11b un-quarantine work: the three target files now pass and run in
CI. A full-suite run with a per-file timeout then surfaced a FOURTH, pre-existing
hang that had never been in the map (dm-block-filter) — quarantined with a triage
note rather than left to hang CI.

user-blocks.test.mjs — post-completion HANG. The decrypt-timeout section installs
a never-resolving nip44Decrypt whose background decrypt-retry timer outlives the
run (manager.reset() races it). Added the repo's established bare-assert
`setTimeout(() => process.exit(0), 50)` exit after the final assertion.

nostr-publish-rejection.test.mjs — post-completion HANG (was NOT in the map, so it
had been silently hanging local `npm run test:unit`, which has no per-file timeout
unless UNIT_TEST_TIMEOUT_MS is set). All assertions pass, then the imported app
stack (app.js + subscriptions + userBlocks) keeps the loop alive. Same
`process.exit(0)`-after-cleanup fix. (The dev-mode + NIP-71 spec fixes were
already in the file.)

nwc-client.test.mjs — (1) mock shadow: readToolkitFromScope prefers the bootstrap's
frozen canonical toolkit over window.NostrTools, so per-section mocks never took
effect and real @noble rejected the fake keys. Remove the scope canonical so the
resolver falls through to each mock. (2) spec correction: one section expected a
rejection when the toolkit "lacks nip44", but mergeWithCanonical ALWAYS backfills
the canonical nip44 (even explicit `nip44:null`) — nip44_v2 is never absent. The
wallet now advertises a genuinely-unsupported scheme (nip44_v3) to exercise the
same UNSUPPORTED_ENCRYPTION path, and asserts error.code too. See TEST_INTEGRITY.md.

dm-block-filter.test.mjs — QUARANTINED (pre-existing, node:test). All subtests pass,
then it hangs: manager.loadBlocks() opens a live block-list subscription through
nostrClient's real SimplePool pointed at the test's fake relay URLs, leaking TCP
sockets + reconnect timers that node:test can't drain. Needs a node:test-safe fix
(inject a mock pool so no real socket opens) — a force process.exit in after() is
UNSAFE (verified: process.exitCode isn't set yet, so it masks failures).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 10:45:55 -04:00