- 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]>
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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
The zap-totals store's pointerKey only understood {type,value} objects, but
deriveVideoPointerInfo produces tag-style ARRAYS (["a", value, relay?]) — so
every card-badge lookup keyed to nothing, no receipt fetch was ever
scheduled, and the Most Zapped ranking metric silently read 0 for every
video. pointerKey now normalizes both shapes; tests use the array shape the
app actually passes.
Also adds the orange sats badge to the video player modal's meta row
(right side via the row's existing justify-between; views stay left),
driven by the same cache/change-signal as the cards and torn down with the
view-count subscription.
Co-Authored-By: Claude Fable 5 <[email protected]>
Trending-pattern clone ranked by sats from kind-9735 receipts:
- js/zapTotals.js: per-pointer receipt aggregation. The feed's metric getter
returns the cached total and schedules a batched fetch (#a/#e chunks via
the L1 SubscriptionManager) for unknown/stale pointers; amounts parse via
nip57 bolt11 with the zap request's amount tag as fallback; receipts are
deduped by id so re-fetches never double-count; zero-receipt answers are
cached (2-min TTL).
- sorters.js: shared count-ranked sorter (metric desc, recency tie-break,
author spread, trusted-muted sink); Trending delegates unchanged, Most
Zapped ranks on runtime.getZapTotal.
- Full wiring: feed definition, view + info popover, change-signal re-rank
(order settles as receipts stream in), sidebar link, viewManager registry,
feedCoordinator case/loader, FEED_TYPES.MOST_ZAPPED, and a
FEATURE_MOST_ZAPPED_FEED flag (default on; off hides the tab).
Co-Authored-By: Claude Fable 5 <[email protected]>
The storage summary gains a connection <select> (hidden with fewer than two
connections) listing every configured connection with the default labelled.
Selection is a per-modal override — loadFromStorage re-resolves the summary
and credentials immediately, stale selections fall back to the default, and
the account default stays untouched. Picker logic lives in
uploadModalStorageUnlock.js for UploadModal's file-size budget.
Co-Authored-By: Claude Fable 5 <[email protected]>
Identity pill top-center on every pane, straddling the header divider, so
users always see which profile they're acting as (e.g. before uploading).
Populated in renderSavedProfiles (same resolveMeta path as the Account pane
card, so it refreshes on profile switch), hidden when logged out, and
click-through jumps to the Account pane.
Co-Authored-By: Claude Fable 5 <[email protected]>
New-user onboarding per docs/onboarding-plan.md: on the first login of a pubkey
on a device, a skippable guided tour escorts the user around bitvid — a scrim
dims the page with a spotlight CUTOUT over each step's anchor, ringed by a
pulsing glow driven by the theme's accent tokens, plus an anchored popover with
Back/Next/Skip, progress dots, and arrow/Esc keyboard nav.
- js/ui/onboarding/tourEngine.js: dependency-free engine. Spotlight = one
element whose 200vmax box-shadow (--surface-overlay-darker) dims everything
except the cutout; an ::after ring animates between --color-accent and
--color-accent-strong. Positioning via CSS custom properties set from JS (the
sanctioned dynamic-var pattern; visual styling lives in an injected token-only
stylesheet). Steps with missing anchors auto-skip, so one script serves all
layouts. Repositions on resize/scroll.
- js/ui/onboarding/bitvidTour.js: the step script (welcome → feeds →
subscriptions → upload → profile → setup CTAs that deep-link into the storage
and wallet panes via profileController.show).
- js/services/onboardingService.js: once-per-pubkey-per-device flags
(bitvid:onboarding:v1, settingsRestorePrompt-style).
- app.maybeOfferOnboardingTour fires 3.5s after login (feed already rendered
thanks to the warm cold-boot work; never blocks login);
app.startOnboardingTour({force}) + a "Take the tour" button in the profile
modal footer make it re-launchable.
- Lint allowlists: tourEngine.js added to the inline-style allowlist (CSS-var
positioning, VideoCard precedent) and the design-token ALLOWED_FILES
(injected stylesheet geometry, RevertModal precedent) — all colors are tokens.
Tests: tests/onboarding-tour.test.mjs (5 — flag lifecycle, tour render/advance/
finish, skip teardown, missing-anchor exclusion, once-vs-force offering, final
CTA deep-link + completion). Lint + build green.
Phases 2-4 (getting-started checklist, empty-state upgrades, fresh-npub create-
account + key backup + kind-0 bootstrap) tracked in the plan + TODO #59.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
bitvid authors kind-30078 and MIRRORS to NIP-71; it never authors NIP-71 directly.
Verified before removing: the My Videos mirror (buildNip71MirrorEvent) derives
everything from the bitvid note itself (duration/dims are auto-captured at upload,
contentWarning comes from the NSFW toggle, imeta is built from the video), and the
only consumer of the form's NIP-71 metadata (publishNip71Video) is gated behind
FEATURE_PUBLISH_NIP71: false — the fields fed a dead path and confused uploaders.
Removed from the upload modal: "NIP-71 Technical" (content warning, duration,
summary + Customize toggle) and the IMETA variant editor. Removed from the edit
modal: kind (21/22) selector, published_at, alt, duration, content warning,
summary, IMETA, text tracks, segments, participants, references. KEPT in both:
hashtags (they feed hashtag feeds, the suggestion chips, and the mirror's t tags)
and the web seeds / torrent (xs) inputs (bitvid-note fields, not NIP-71). All the
JS plumbing is missing-DOM tolerant, so no wiring changes were needed to collect.
Compatibility: existing videos' NIP-71 extras survive a save. EditModal keeps the
video's full stored metadata (storedNip71Metadata) and merges the edited hashtags
into it on submit instead of replacing it with the reduced form's empty output
(t mirror key kept in sync).
Also fixes a latent pre-existing bug the reduction exposed: nip71Edited compared
the submit-side copy AFTER publishedAt normalization (string→timestamp,
delete-empty) against an UN-normalized original, so any video with publishedAt
made every save look "edited". Both sides now share normalizeCollectedNip71.
Tests: +2 in edit-modal-submit-state (stored extras survive a hashtag edit;
untouched save keeps nip71Edited false). Modal/payload/mirror suites green
(39 + 7). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The upload modal's Hashtags section now shows chips of the user's most-used past
hashtags ("Reuse a tag from your videos:"). Clicking one adds it to the NIP-71 't'
repeater; already-added chips dim + disable; the row is hidden for users with no
past tags.
- js/utils/hashtagSuggestions.js: pure rankHashtagsByFrequency(videos) — count =
number of the user's videos using each tag, normalized (case + '#') and deduped
per video, ranked by count then alphabetically. Reads video.nip71.hashtags, falls
back to raw event 't' tags minus bitvid's fixed t=video marker.
- app.getUserHashtagSuggestions() sources the user's own videos via
nostrService.getActiveVideosByAuthors([pubkey]) and ranks them (top 12), injected
into the modal as getHashtagSuggestions.
- UploadModal renders the chips on open and wires clicks through the existing
Nip71FormManager (addRepeaterEntry + setFieldValue, dedup via collectRepeaterValues).
Tests: tests/hashtag-suggestions.test.mjs (5 — per-video counting, normalization,
marker exclusion, tie-break + limit, empty input). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The opt-in "keep unlocked" cache had no user-facing way to undo it mid-session
(it cleared only on logout / clear-site-data). Add a "Lock this device" button to
the profile modal footer (desktop + mobile), shown only when the active account
has a cached key to forget (app.isSessionKeptUnlocked -> client.hasCachedUnlockedKey).
Clicking it runs app.lockKeptUnlockedSession(pubkey), which re-locks in one action
everything the signer unlocks:
- forgets the cached key (both tiers) so future reloads re-prompt for the PIN,
- drops the in-memory signer (logoutSigner) so the CURRENT session re-locks now
(the next signing action prompts again via ensureEncryptionCapableSigner),
- storageService.lock(pubkey), and
- nwcSettingsService.clearCache() (re-hydrates from the signer next time).
Visibility refreshes on renderSavedProfiles + selectPane. Tests: 2 in
tests/profile-modal-controller.test.mjs (shown only when kept-unlocked; click locks
the active pubkey). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The admin pane stacked every list (Moderators / Whitelist / Blacklist) in one
scroll, and the per-event block list had no admin UI (block-only via the ⋯ menu,
no view/unblock). This reorganizes the pane into a sub-tab toolbar and adds the
missing Blocked-videos tool.
- Sub-tabs: a role="tablist" toolbar in #profilePaneAdmin switches between
Whitelist (default), Blacklist, Blocked videos, and Moderators. Moderators is
super-admin-only — its tab button is hidden otherwise. selectAdminSubtab()
shows one section at a time (ARIA + data-state), falling back to the default
when a hidden tab is requested.
- Blocked videos: view the per-event admin block list, unblock per row (confirm
→ removeFromEventBlacklist), and add by nevent/hex id. Rows show the video
title+author when it's in the local cache, else the shortened id. Reuses the
existing accessControl event-blacklist API + onEventBlacklistChange (live
re-render) and refreshes the grids on change. No backend changes.
- Row-building/render logic in js/ui/profileModal/blockedVideosSection.js
(keeps ProfileAdminController under its size cap; unit-testable without a DOM).
Spec correction: profile-modal-controller.test's admin assertion updated for the
sub-tab layout (Moderators section now hidden until its tab is active; its tab
button is shown to a super admin; selecting it reveals the section).
Tests: tests/profile-blocked-videos.test.mjs (7 — rows dedupe/enrich/fallback,
render, unblock wiring); profile-modal-controller admin test updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The My Videos tab already lists bucket objects no live note references and offers
per-file delete; this adds the bulk action. A "Delete all (N)" button (shown only when
there's more than one orphan) confirms once, then removes every listed key in a single
deleteStorageKeys({ keys, pubkey }) call — which is already array-capable, unlock-gated,
and scoped to the user's prefix. Refreshes the list on success, reports partial results
honestly ("Removed 2 files (1 could not be deleted)"), and surfaces storage-locked as an
unlock prompt instead of a false success.
Tests: tests/my-videos-bulk-orphan-delete.test.mjs (5 — all keys sent in one call,
cancel deletes nothing, storage-locked prompt, partial-count message, empty no-op).
Build + lint clean; My Videos suite 37/37.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The first cut showed for Custom S3 too but always gave B2-only instructions (b2 CLI +
B2-native JSON), and was hidden for R2. Now the helper shows for all bucket providers
and tailors its content:
- Backblaze B2 -> B2-native rules + `b2 update-bucket --corsRules ...`
- Custom S3 -> standard S3 CORSConfiguration + `aws s3api put-bucket-cors
--endpoint-url <endpoint> ...`
- Cloudflare R2 -> standard S3 JSON + Cloudflare dashboard guidance (R2 -> bucket ->
Settings -> CORS Policy) and the AWS-CLI command vs the derived R2
endpoint.
Origins pre-filled from getCorsOrigins(); intro/command-label/notes set per provider;
copy buttons for both blocks. bitvid still auto-applies CORS on Save (R2/S3 via
ensureBucketCors) — this is the manual fallback when the key lacks CORS-write perms.
buildCorsHelpContent() switches on provider; modal text is now data-driven. Tests
extended to 10 (per-provider rules + command + a DOM provider-switch). Build + lint
clean; storage suites 40/40.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Confirmed against the real B2 account: the web-console "Share everything..." presets
write DOWNLOAD-only CORS (s3_get/s3_head, b2_download_*) with NO s3_put/s3_post, so
bitvid's browser uploads CORS-fail. The web UI can't set custom rules — it points
users at the B2 CLI.
Adds a "CORS setup help" button (shown for B2 / Custom S3, hidden for R2) that opens a
modal with:
- the exact CORS rules JSON in B2-native shape (upload + ranged-playback ops; origins
pre-filled from getCorsOrigins()), with a Copy button,
- the `b2 update-bucket --corsRules '<json>' <bucket> allPublic` command to apply it,
- step-by-step + the "use a key with writeBucketCors and bitvid sets it for you" tip.
CORS logic extracted to js/ui/profileModal/storageCorsHelp.js (pure builders +
StorageCorsHelp modal controller) so ProfileStorageController stays under the 1000-line
cap. Tests: tests/storage-cors-help.test.mjs (8 — asserts the rules include the upload
ops the presets omit, origin pre-fill, command shape, modal open/close/visibility).
Build + lint clean; profile-modal + storage suites 32/32.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
B2 doesn't map onto the generic-S3 "paste an endpoint" model: its S3-compatible
endpoint is region-scoped (s3.<region>.backblazeb2.com) and a public bucket is
addressed virtual-hosted-style, so the public download URL is
https://<bucket>.s3.<region>.backblazeb2.com/<key> — not derivable from a pasted
generic endpoint. So B2 silently failed under "Custom S3".
- s3-url: deriveB2Endpoint(region) -> https://s3.<region>.backblazeb2.com (empty for
auto/blank/pasted-host so callers can require a real region); re-exported from
s3Service.
- storageService: PROVIDERS.B2 + resolveB2Endpoint (explicit endpoint wins, else
derive) + PROVIDER_TESTS[B2] (derived endpoint, forcePathStyle false).
- Public URL = S3-style virtual-hosted, auto-derived; an explicit Public Access URL
(custom domain/CDN) overrides it. The save path now forwards the user's public URL
to prepareS3Connection (also fixes generic-S3 ignoring a custom CDN URL when blank
-> still derives, so no regression).
- UI: "Backblaze B2" provider option + helper; raw Endpoint hidden for B2 (region
drives it), region shows a us-west-004 example, force-path-style toggle hidden;
save/test derive endpoint from region with a clear "enter your region" error.
UploadModal shows B2-specific labels.
- Tests: tests/storage-b2-provider.test.mjs (8). All s3/storage suites green (50);
build + lint clean.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The embed submission forms accepted any free-text identifier and published it,
so they were an easy spam vector. Add a shared, testable NIP-19 validator and
require a well-formed npub before a submission is sent.
- js/utils/validateNpub.js: validateNpubHex() decodes via nostr-tools nip19 and
returns the 64-char hex pubkey, or null for anything that isn't a real npub
(free text, wrong prefix, bad checksum, wrong NIP-19 entity). Also exposed as
window.validateNpubHex for the classic iframe form scripts.
- Application form (required npub): submit is blocked unless valid; the decoded
hex is written into the application body for canonical storage/matching.
- Feature-request / bug-fix / feedback (optional npub): stays optional, but a
non-empty value must be a valid npub or submit is blocked with an inline error.
- Tests: tests/validate-npub.test.mjs (real nostr-tools, no implementation mirror).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The real reason every tab looked identical: VideoListView.render re-sorts
displayVideos by date UNLESS metadata.preserveOrder is set, and only Explore set
it. So For You's tiering, Trending's view-count ranking, and Kids' scoring were
all discarded at render and shown in plain chronological order — i.e. the #21
feed-identity work was invisible. Set preserveOrder for For You, Trending, and
Kids so their feed-engine order survives to the grid.
Also per request:
- Sidebar: Trending now sits above Recently Added.
- Default homepage: logged-out users land on Trending (gated by
FEATURE_TRENDING_FEED, falls back to Recent); logged-in stays For You. The
logout-from-For-You redirect follows the same rule.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
New FEED_TYPES.TRENDING tab: the active (recently-added) source ranked by view
count (recency tiebreak, trusted-muted last), gated by FEATURE_TRENDING_FEED
(default on). Builds on the now-trustworthy view counts (#4).
- createTrendingSorter reads counts from runtime.getViewCount, which resolves
each video's pointer via the app's canonical deriveVideoPointerInfo against the
shared viewCounter cache (new getVideoViewCountSnapshot export) — the same
counts the grid cards already load.
- Live re-rank: viewCounter now emits a coalesced onViewCountsChanged signal;
while the Trending view is active it debounce-re-runs the feed (cheap re-rank
over the in-memory active set, no relay re-fetch), so the order settles into
true trending as counts stream in. Cold cache reads as recency, then improves.
- Pipeline extracted to js/feedEngine/trendingFeed.js (feedCoordinator is at its
size cap; bumped the cap for the thin load/refresh delegations).
- Wiring: FEED_TYPES + flag, feedCoordinator cases, app.js wrappers,
applicationBootstrap registration (hides the sidebar link when flag off),
viewManager init + js/trendingView.js, views/trending.html, sidebar link.
Tests: tests/trending-sorter.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds an opt-in account preference (off by default, per pubkey) that
auto-mirrors newly published eligible public videos to NIP-71
(kinds 34235/34236) so they surface on other Nostr video clients —
no per-video opt-in needed.
- nip71MirrorFlags: isAutoShareEnabled/setAutoShareEnabled (persisted
per pubkey) + pure resolvePublishSync({featureOn,autoShare,eligible}).
- nip71MirrorSync: syncNip71MirrorAfterPublish + a videos:published
subscription in initNip71MirrorSync; converts the canonical 30078
event, checks eligibility (public + hosted URL + ALLOW_NSFW gate),
publishes the mirror, and records the per-video flag so later
edits/deletes stay in lockstep. All best-effort.
- My Videos pane: an "Auto-share new public videos to other Nostr apps"
checkbox wired to setAutoShareEnabled, gated by FEATURE_NIP71_MIRROR.
Tests (mutation-verified): auto-share persistence, resolvePublishSync
(eligible+on=>publish; off/ineligible=>none), and videos:published
subscription wiring.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds encrypted cross-login sync for the NWC wallet connection (todo #15),
reusing the encrypted-sync core under d-tag "bitvid:nwc".
- js/services/settingsSyncFlags.js: shared per-pubkey opt-in flag store
(storage/wallet); storageSyncService refactored onto it (identical behavior,
tests still green).
- js/services/walletSyncService.js: factory (NwcSettingsService is per-app, not a
singleton). push() syncs the current nwcUri (+defaultZap); pull() applies it via
updateActiveNwcSettings; disable() wipes the note.
- Wallet pane (components/profile-modal.html + ProfileWalletController): opt-in
toggle + Restore button + a strong SPENDING warning, shown only when a signer
can encrypt. Enabling requires an explicit window.confirm (the URI can spend).
Re-pushes on save when enabled; and on wallet DISCONNECT while synced it clears
the note so a removed spending URI doesn't linger (encrypted) on relays.
tests/wallet-sync-service.test.mjs: enable-pushes-uri, pull-restores-via-
updateActiveNwcSettings, disable-clears, no-wallet-doesn't-publish. Mutation-
verified (skip-apply + skip-clear both fail).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Wires the storage-credential sync (6f7f4196) into the profile Storage pane.
UI (components/profile-modal.html): a "Cross-device sync" card shown only when
storage is unlocked — an opt-in toggle ("Sync these settings to my Nostr account
(encrypted)"), a "Restore from my Nostr account" button, the public-relay /
key-compromise warning copy, and a status line.
Controller (ProfileStorageController):
- toggle on -> storageSyncService.enable() (encrypt + publish), with rollback +
message if nothing is stored yet; toggle off -> disable() (wipe the note)
- restore -> pull() + re-render the pane so the restored connection shows
- after a successful Save, re-push the synced copy when sync is enabled
Also routes the core's relay READ through the subscription manager (injected
listEvents) instead of pool.list, satisfying lint:pool-access for the new file;
the facade supplies it via nostrClient.getSubscriptionManager().list.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
After the note-side list renders, the My Videos tab now reconciles against the
bucket: lists the user's objects (unlock-gated), then marks any active note whose
hosted file is gone as a 🟠 "Missing file", and lists bucket objects no current
video references in an "Orphaned storage objects" section — including files left
behind by videos deleted on Nostr (the headline cleanup case). Each orphan has a
"Delete file" button (with a confirm) that calls deleteStorageKeys and refreshes.
Reconciliation runs after the list is shown so the UI stays responsive, and is a
no-op (orphan section hidden) when storage is locked/unconfigured. Rows carry a
data-video-id + a data-role pill so missing status can be applied in place.
Build + full lint green (DOM via createElement/textContent, no innerHTML).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds a "My Videos" tab to the profile modal that lists every video the logged-in
user has published — collapsed to one row per video in its current state
(including tombstoned ones) — each with a note-side health pill: red "No playable
source", grey "Deleted on Nostr", green "OK". Shows a summary line
(N videos · M need attention · K deleted) and refreshes from relays on open.
- New MyVideosController (lazy-populated via selectPane, like the other panes):
fetchVideosByAuthors -> collapseUserVideos -> classifyVideoHealth -> render.
Rows built with createElement/textContent (no innerHTML; user titles/URLs are
not interpolated into markup). Status pills reuse the relay-pill token styles.
- Sidebar nav button + pane markup in components/profile-modal.html.
- Wired into profileModalController (cache refs, listeners, selectPane hook);
the existing nav-button loop auto-binds the click.
This is display-only; later steps add the hosted-URL HEAD probe (dead-url), row
actions (edit/delete/revert/storage cleanup), and Phase 2 bucket reconciliation.
Build + full lint green (no new file-size/innerHTML/token violations).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Brings the Edit modal to upload parity with the Upload modal: re-upload a
thumbnail or video file instead of only pasting URLs.
- Wire storage services (r2/s3/storageService) + pubkey/npub accessors into
EditModal via ModalManager/initEditModal; EditModal constructs a shared
MediaUploader.
- New editModalUpload.js holds all the upload glue (file-picker wiring, progress,
storage-unlock checks, field write-back) to keep EditModal under its file-size
budget. EditModal calls initEditModalUpload(this, context) in one line.
- edit-video-modal.html: "Replace video file…" (under the URL field) and
"Replace thumbnail file…" (under the thumbnail field), each with a hidden file
input + status line.
- On thumbnail upload: writes the new URL into the thumbnail field. On video
upload: writes url + magnet (+ ws/xs hints). All written via setFieldUnlocked,
which marks the field edited so EditModal.collect actually persists it (gated
behind storage being configured + unlocked).
setFieldUnlocked is unit-tested (mutation-verified): without the unlock, the
re-upload would silently not save. Build + full lint + unit suite green.
NOTE: the upload/storage flow itself can't be unit-tested (browser + R2/S3);
pending manual verification of editing a video's thumbnail/file.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Relay tab improvements so users can see which relays to remove:
- Per-relay status pill: Online (with ping) / Offline / Checking… / Blocked.
- Insecure remote ws:// relays (CSP-blocked) flagged immediately with a hint.
- Offline relays show a "consider removing" hint; their Remove button is
emphasized.
- Summary banner when relays are down (incl. the all-dead case explaining why
videos won't load), pointing at Restore defaults / healthy relays.
Also fixes a pre-existing decomposition bug (commit 7300977a): handleRelayOperation
called this.runRelayOperation, but that method lives on the main
ProfileModalController — so ALL relay ops (add/remove/mode/restore) threw
"this.runRelayOperation is not a function". Route via this.mainController.
Tests: relay-insecure-url (isInsecureRelayUrl), relay-operation-routing (routes to
mainController.runRelayOperation, guards no-pubkey). Both mutation-verified.
Build + lint + full unit suite green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Completes end-to-end multi-webseed support: the editor can now see, add, and
remove every web seed a video carries.
- magnetShared: promote normalizeWebSeedList to a shared export (videoNotePayload
now imports it) and add setMagnetWebSeeds(), which replaces a magnet's COMPLETE
ws= set with exactly the requested list. ensureWebSeeds is append-only, so
without set-semantics a webseed a user deleted would linger in the magnet;
setMagnetWebSeeds strips the existing ws= params first, making removal stick.
- edit-video-modal.html: the single "Web seed (ws=)" input becomes a "Web seeds
(one per line)" textarea with backup-origin help text.
- EditModal: open() populates the field from extractAllWebSeeds() (every seed,
not just the first). On save, when the field was manually edited the magnet's
ws set is rebuilt from the field via setMagnetWebSeeds (true add/remove);
otherwise existing seeds are preserved untouched. The content `ws` field still
stores the primary (first) seed while the magnet carries the full set.
Mutation-verified (no-strip and no-add both fail the removal/add tests); build,
relevant lints (inline-styles, tokens, innerHTML), and full unit suite green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Let uploaders declare a backup origin at creation time so the swarm can keep
streaming when the primary CDN is overloaded.
- upload-modal.html: the single "Web Seed (ws)" input becomes a "Web seeds
(one per line)" textarea with help text explaining the backup-origin use
case and the byte-identical-file requirement.
- UploadModal: in upload mode the R2/CDN url stays the primary webseed and any
textarea entries follow it (passed as [primaryUrl, textarea]); external mode
already flowed the field through. All parsing stays in the payload layer.
- videoNotePayload.normalizeWebSeedList now splits every entry (array OR
string) on newlines/commas, so the upload-modal's [url, "a\nb"] shape and a
plain multi-line string both expand + dedupe correctly.
Mutation-verified (array entries no longer split fails the upload-shape test);
build + full unit suite green. Lints relevant to the change (inline-styles,
tokens, innerHTML) pass.
Note: npm run lint also reports pre-existing file-size violations in unrelated
files (SignerManager, watchHistory, playbackService, webtorrent, etc.) that
grew in earlier commits — none are touched here. Flagging separately.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Follow-up to c79120ba (which only captured the file deletion): removes the
sidebar nav link and the unused DEV_DASHBOARD view constant. No dangling refs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- `handleVisibility` only scheduled intervals but didn't trigger an immediate refresh when returning to the tab.
- This caused `tests/unit/services/exploreDataService.test.mjs` to fail (assertion `0 !== 1`).
- Added explicit calls to `refreshWatchHistoryTagCounts` and `refreshTagIdf` when `document.hidden` becomes false.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Add "Dev Tools" button to the sidebar footer menu.
- Configure `js/constants.js` with `DEV_DASHBOARD` view type.
- Refactor `views/dev/agent-dashboard.html` to be compatible with `loadView` (moved styles to body, updated links).
- Add cleanup logic to dashboard script to prevent memory leaks when navigating away.
- Fix duplicate import in `js/nostr/reactionEvents.js`.
Co-authored-by: PR0M3TH3AN <[email protected]>
Added `loading="lazy"` and `decoding="async"` to author avatar images in `VideoCard.js` and the creator avatar in `video-modal.html`. This aligns these components with `SimilarContentCard.js` and `VideoCard` thumbnails, improving initial page load performance and reducing main-thread blocking by deferring off-screen image loading and decoding.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Throttle the "Privacy warning: direct messages are using your default relays..." message so it only appears once per session/identity change.
- Rename "DM Relay Hints" to "NIP-17 Relay List" in the DM Settings modal to match the warning message and reduce user confusion.
- Add regression test for the warning throttling logic.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Replaces arbitrary `max-h-[90vh]` with tokenized `max-h-modal-shell` in `components/dm-settings-modal.html` to satisfy `lint:tailwind-brackets` check.
- This accompanies the documentation updates for content uploads.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Created `DMSettingsModalController` to manage the new settings modal UI.
- Added `components/dm-settings-modal.html` with sections for Privacy and DM Relay Hints.
- Updated `AppShell.js` to delegate the settings action to `onOpenSettings` callback, bypassing the legacy popover.
- Updated `ProfileModalController.js` to wire up the new modal controller, handling state persistence for relays and privacy settings.
- This improves the UX for managing NIP-17 DM relay hints and privacy options.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Remove separate relay health panel from profile modal.
- Merge health indicators (latency, errors) directly into the relay list items.
- Consolidate refresh logic to update both list configuration and health status.
- Move telemetry toggle to the bottom of the relay section.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Replaced `h-[80vh]` with standard `h-4/5` in `components/subscription-history-modal.html` to satisfy `lint:tailwind-brackets` token rules.
Co-authored-by: PR0M3TH3AN <[email protected]>