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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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 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]>
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]>
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]>
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]>
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]>
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]>
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]>
executeRetry now fires the same onZapSuccess hook as the main send path with
the retried shares' proof, so a zap completed via the retry flow gets its
optimistic bump and preimage-verified tally publish too (was previously only
on the first-attempt path). Closes the retry-path item in the plan checklist.
Co-Authored-By: Claude Fable 5 <[email protected]>
Third slice of docs/zap-tally-plan.md §5.3-5.5 — the publish side:
- zapSplit.js: surface receipt.preimage (NWC pay_invoice result.preimage).
- zapController.js: onZapSuccess now forwards per-share proof
{recipientType, amountSats, bolt11, preimage, zapRequest} for every settled
share with a preimage.
- zapTallyPublisher.js (new): buildTallyFromShare (pure — builds the addressable
tally from a share's bolt11/preimage/zap-request, d=payment_hash, pointers
from the zap request) + publishZapTallies (best-effort, flag-gated, never
throws).
- ModalManager.js: onZapSuccess keeps the instant optimistic bump AND publishes
the tallies via nostrClient.signAndPublishEvent to write relays (an
all-timeout publish is a soft success per #49). A publish failure is
non-fatal — the durable local ledger still shows the sender their zap.
With the counting slice (025ab348), a sent zap now: shows instantly (ledger),
publishes a verified tally, and every bitvid client counts it — surviving the
recipient never publishing a 9735. Tests: tests/zap-tally-publisher.test.mjs
(5). Lint + build green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Mirrors ingestLocalViewEvent for view counts: sending a zap through the
video modal now bumps that video's zap total immediately on the card + modal
badge, instead of waiting for the relay receipt (delayed by the 2-min cache
TTL, and dependent on the recipient's wallet actually publishing a 9735).
- zapTotals store tracks optimisticSats separately from real receipt sats;
getSnapshot/request return the sum. When the real receipt for the pointer
later arrives, runBatch clears the optimistic portion so it's never
double-counted. If the receipt never lands (custodial wallets often don't
publish), the bump persists in-memory until reload — keeping the zapper's
own view honest.
- ZapController fires a new onZapSuccess({video, sats}) at the fully-success
terminal point (sats = context.shares.total; both creator + platform shares
tag the video, so the eventual relay total matches).
- ModalManager derives the pointer via app.deriveVideoPointerInfo (the SAME
key the cards/modal subscribe with) and calls ingestLocalVideoZap.
Live-verified: a rendered card's badge went 0 sats -> 5K sats instantly on
ingest, no relay round-trip. Test covers the bump + replace-on-real-receipt.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diagnosis (live, verified): the zap-totals pipeline is correct end to end —
a seeded receipt queries and sums to the right sats — but of 310 kind-9735
receipts reachable on the relays, ZERO reference any bitvid video, so every
badge was legitimately hidden. That reads as "broken" and profile zaps to a
Lightning address never attach to a video, so hidden-when-zero left the
feature invisible in practice.
Show the counter always, like the view counter it sits beside: "0 sats"
until a receipt lands, then the summed total. Card binder and modal render
no longer gate on sats>0; the badge is revealed on bind/open and re-hidden
on modal close (no video open). Live-checked: 158/158 cards render the
badge. Tests spec-corrected with integrity notes.
Co-Authored-By: Claude Fable 5 <[email protected]>
Two fixes from live testing:
- "NWC doesn't sync unless I click Restore": devices with sync ENABLED only
ever pushed-on-save — edits from another device sat unseen until the
manual Restore click. encryptedSyncItem.autoPullIfNewer pulls the remote
note at login when its created_at is newer than this device's last-synced
marker (set on push AND now after a successful pull, so the same note is
never re-pulled and the next conflict check stays honest). Wired into the
login restore flow ahead of the one-time offer; disabled items are never
touched.
- "Could not get user information" on a known-good creator address: that
reason string comes from the LNURL SERVER, and the failing fetch was the
PLATFORM-FEE address, not the creator's. prepareLightningContext now
wraps both metadata fetches so the toast names which address failed
(e.g. "Platform fee Lightning address (…): Could not get user
information") instead of blaming the creator.
Co-Authored-By: Claude Fable 5 <[email protected]>
Video cards show the total sats from the shared zapTotals cache: new
--color-zap token (bitcoin orange, both themes), badge pushed to the
engagement row's end so views/comments stay left-aligned, hidden until a
nonzero total is known. The list view's zap binder fills [data-zap-total]
on bind (also scheduling the batched receipt fetch) and one shared change
listener updates every bound card as receipt batches land.
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]>
signAndPublishEvent (video publish/edit/repost/mirror), rebroadcastEvent, and
revertVideo now use assertAnyRelayAcceptedOrUnconfirmed: when every relay
times out without rejecting, the event was sent and almost always persisted,
so it no longer surfaces as "Failed to share video". Explicit rejections
still throw.
Messaging stays honest at every consumer:
- relay tally carries `unconfirmed` (now attached on edits too);
describePublishOutcome maps 0-accepted+unconfirmed to a WARNING ("sent,
no relay confirmed yet") — never a fake relay count, never a hard error
- edit/revert controllers say "sent, not confirmed yet" when unconfirmed
- deletes stay CONSERVATIVE: "deleted successfully" only when >=1 relay
ACKed a tombstone or kind-5 (anyRelayAcceptedInSummaries); an unconfirmed
revert no longer aborts deleteAllVersions midway, so the kind-5 always runs
Tests: tests/relay-ack-unconfirmed-content.test.mjs (6).
Co-Authored-By: Claude Fable 5 <[email protected]>
All three paths (upload video pick, upload thumbnail pick, edit "Replace
file") now route through the shared signer gate — silent kept-unlocked
restore, else ONE passphrase prompt — then unlock storage and continue the
same pick. Cancel/bad-passphrase abort quietly; a refused thumbnail pick
restores the picker. Unlock glue extracted to uploadModalStorageUnlock.js
(file-size budget); ensureSigner threaded via initUploadModal/initEditModal.
Tests: tests/upload-edit-storage-unlock-gate.test.mjs (12).
Co-Authored-By: Claude Fable 5 <[email protected]>
The Author field (and the author: token) passed values through as-typed, but the
matcher compares against video.pubkey — HEX. So entering an npub (the only format
users actually have) silently returned zero results; only raw hex worked.
New normalizeAuthorValues (searchFilters.js, injectable decoder for tests):
npub1* decodes via safeDecodeNpub to hex, hex passes through lowercased, and
undecodable values are dropped — surfaced as parse errors on the author: token
path. Applied in BOTH the parser and the filter modal's applyFilters, so the
Author panel field and the token syntax now genuinely filter by creator.
Tests: +2 in tests/search-filters.test.mjs (decode/passthrough/drop with an
injected decoder; parser hex passthrough + garbage error). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Step 2 (sidebar) left the user "stuck looking at a glow with nothing to do": the
sidebar is a full-height column, so the below/above-only placement computed a
popover position past the bottom of the viewport — the card existed but was
invisible off-screen.
Placement is now a pure, exported decision (computeTourPlacement): try below →
above → beside-right → beside-left, whichever actually fits the popover, with a
centered-over-the-dim fallback when nothing does (viewport-filling targets) — and
coordinates are clamped so the card can NEVER leave the viewport. The sidebar
step also hints placement: "right". A preferred placement that doesn't fit falls
back to one that does.
Tests: +1 in tests/onboarding-tour.test.mjs — full-height column → beside-right
inside the viewport; button → bottom; near-bottom target → top; viewport-filling
target → centered; unfittable preference falls back and never goes below the
fold. Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[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 real #54 bug (the signer gate was correct but unreachable): the reactions
controller forwards the raw click event to VideoModal.handleReactionClick, which
called this.reactionsController.handleReaction(event) — a method that does NOT
exist on the video-modal reactions controller (it only binds buttons + does
optimistic UI), passing the DOM event as the "reaction". So clicking like/dislike
threw / silently did nothing and never reached the publish path.
Fix: handleReactionClick now derives +/- from the clicked button
(#modalLikeBtn / #modalDislikeBtn, or a bare "+"/"-" for programmatic callers) and
dispatches "video:reaction" — the event ModalManager already routes to
app.handleVideoReaction -> reactionController.handleReaction (signs + publishes the
kind-7, and runs the ensureEncryptionCapableSigner gate from the earlier #54 pass).
Note: #53 (channel avatar/name link) is already fixed in a20388c5
(handleCreatorNavigation now dispatches "creator:navigate"); if it still looks dead
it's a stale build — rebuild dist / redeploy the branch under test.
Tests: tests/video-modal-controllers.test.mjs (+2). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The edit-modal external-URL webseed was ws=-only because the Edit modal had no
storage credentials wired in. Reuse mediaUploader.resolveActiveConnection() (the
same resolver the Edit modal's file-replace uploads use) to get the active
provider + credentials when storage is unlocked, and pass them to
deriveTorrentForExternalUrl so it hosts the tiny .torrent and emits ws=+xs= — full
P2P bootstrap, matching the upload modal. Storage locked / no connection → ws=-only
(unchanged, best-effort).
Edit-modal submit-state + upload-field + external-url-torrent suites green.
Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Mirror the upload-modal external-URL webseed for the Edit modal: when the user
changes a video's URL to an external link with no magnet, auto-derive the webseed
torrent (fetch + hash the remote file → magnet ws=url) so an edited external URL
keeps the P2P benefit. Best-effort + size-capped; on CORS / too-large / any error
it stays URL-only. Runs before the existing magnet normalization so the derived
magnet's ws/xs are extracted the same way a pasted magnet's are.
submit() is now async (it was fire-and-forget from the form handler; the
pendingSubmit guard already prevents re-entry, so it just stays "pending" during
the derivation).
Known gap: hosting the tiny .torrent (xs=) on the edit path needs storage-
credential plumbing the Edit modal doesn't currently have — so the edit path is
ws=-only for now (upload path hosts xs= when storage is unlocked). Tracked as a
follow-up.
Edit-modal submit-state + upload-field tests green. Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Posting an external URL used to skip the torrent/webseed benefit entirely. Now the
upload modal's external tab auto-derives a WebTorrent webseed from the URL at
publish time: fetch the remote file (CORS-permitting), stream it under a hard size
cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=).
Best-effort — CORS failure / over the cap / any error silently degrades to
URL-only. Only runs when the user didn't paste their own magnet.
- js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap,
aborts past it, short-circuits on content-length), buildWebseedMagnet, and
deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit
tested without network/WebTorrent.
- mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when
storage is unlocked, also hosts the tiny .torrent so the webseed can actually
bootstrap P2P (xs=) — best-effort, falls back to ws=-only.
- UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the
.torrent when storage is unlocked, shows progress on the submit button, and
never throws.
Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing,
streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected-
hash, failure propagation). Lint + build green.
Next: the same for editing a URL in the Edit modal.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Clicking the creator avatar/name in the video player modal did nothing. VideoModal
dispatched "navigate:profile", but ModalManager only listens for "creator:navigate"
(→ app.openCreatorChannel), so the event went into the void — same class as the old
action:embed bug noted just above the handler.
Fix:
- VideoModal.handleCreatorNavigation dispatches "creator:navigate" (the listened name)
with { pubkey }.
- ModalManager's handler forwards event.detail.pubkey to app.openCreatorChannel.
- routerCoordinator.openCreatorChannel(pubkey) accepts an optional pubkey (prefers it,
falls back to the active video's creator), so it navigates to the modal's own
creator rather than whatever currentVideo happens to be.
Tests: tests/video-modal-controllers.test.mjs (+2 — dispatches creator:navigate with
the pubkey and never the dead navigate:profile; no-op without a pubkey). 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 upload modal only READ storage's unlock state (isUnlocked) on load/open/refresh
— it never auto-unlocked the way the profile Storage pane does. So after a refresh
with "keep unlocked" ticked, the upload modal still opened with storage Locked even
though the signer had been restored.
Add UploadModal.maybeAutoUnlockStorage(pubkey), called on load(), open(), and
refreshState(): if storage is locked but a decrypt-capable signer is already present
(e.g. a kept-unlocked nsec restored on refresh), unlock silently. Guarded two ways:
- only when the account ALREADY exists (new storageService.hasStoredAccount) so it
never auto-creates storage for magnet/URL-only uploaders, and
- only when the signer is already available, so it never triggers a fresh
permission prompt on modal open.
Tests: storageService.hasStoredAccount (false before setup, true after unlock,
never creates an account, survives lock). 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]>
After unlocking a saved nsec key the user had to re-enter the PIN on every reload,
because the key is only passphrase-encrypted and the in-memory signer is lost on
refresh. Add an opt-in unlocked-key cache with two tiers (maintainer's choice):
- session (default, always): sessionStorage — survives refresh/navigation, cleared
when the tab/browser closes. Same rough exposure as the in-memory signer.
- persistent (opt-in): the unlock prompt now shows a "Keep me unlocked on this
device" checkbox with an inline unencrypted-at-rest warning; when checked the key
is also written to localStorage and survives until site data is cleared.
client.restoreUnlockedSigner(pubkey) re-registers the signer from the cache WITHOUT
a passphrase — it verifies the cached key derives to that pubkey and forgets a
mismatch, so it never adopts another account. It runs proactively from
ensureActiveSignerForPubkey (auto-login/refresh) and lazily from
ensureEncryptionCapableSigner before any prompt, so after a reload the signer is
restored silently and storage/hashtags/DMs/reactions/blocks/subs all just work.
Logout and remove-saved-profile forget the cached key (forgetUnlockedSigner).
SECURITY: while cached, the key is stored decrypted — the passphrase no longer
protects it at rest. Session tier is the default; persistence is opt-in + warned.
Tests: tests/unlocked-key-cache.test.mjs (8 — session vs persistent tiers, promote
persistent->session on read, reject non-hex, forget one, clear all, missing-storage
degrade). Related unlock/switch/session-actor suites green. Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Fast-follows to the ensureEncryptionCapableSigner gate: every remaining
signing/encryption flow now re-unlocks a reloaded nsec signer with one passphrase
prompt instead of dead-ending. Each call site short-circuits ONLY on the
user-driven outcomes (cancel / bad-passphrase); every other case falls through to
the flow's own existing error handling, so non-nsec signers (e.g. an unresponsive
NIP-07 extension) behave exactly as before.
- Reactions (#54): ReactionController.handleReaction gates before the optimistic
update with need:"sign" (kind-7 reactions don't need encryption) — fixes the
"like/dislike does nothing" that was a rolled-back publish on a lost signer.
- Blocks: handleProfileBlocklistMutation (need:"encrypt", NIP-04 mute/block lists).
- Subscriptions: the channel Subscribe/Unsubscribe toggle (need:"encrypt", kind 30000).
- DM send: both composers — handleSendProfileMessage and handleDmAppShellSendMessage
(need:"encrypt", NIP-17/NIP-04).
Reactions get the gate via a new ensureSigner callback on ReactionController; the
modal flows use the services.ensureEncryptionCapableSigner already exposed.
Tests: tests/reaction-signer-gate.test.mjs (3 — cancelled/bad-passphrase block
publish, ok proceeds, asserts need:"sign"). Related suites (decision, DM, profile
modal, switch delegators) green. Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A persisted nsec session restores the pubkey + UI on reload but NOT the in-memory
signer (the private key is only passphrase-encrypted — TODO #36). So after a refresh,
adding/editing a hashtag on an nsec account dead-ended with "Connect a Nostr signer
that supports encryption before managing hashtag preferences" — even though the user
looks logged in. Same class blocks subscriptions/blocks/DMs/reactions.
New reusable gate app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage }):
checks the active signer's capabilities; if it can't encrypt/sign AND a MATCHING
locked nsec key exists for the account, prompts once for the passphrase and re-unlocks
the signer app-wide via unlockStoredSessionActor. One unlock re-enables every signing
flow until the next reload. Never unlocks a different account's key (pubkey-mismatch
guard); "no-stored-key" leaves non-nsec cases (e.g. an unresponsive NIP-07 extension)
to the caller's own error. Branching lives in js/nostr/ensureSignerDecision.js (pure,
unit-testable without the app shell).
Wired hashtag add/edit through it (ProfileHashtagController.persistHashtagPreferences):
a cancelled prompt aborts silently, a bad passphrase shows its own toast, and publish
never runs until the signer is confirmed. Exposed to the modal as
services.ensureEncryptionCapableSigner.
This is the shared machinery for the whole reloaded-nsec cluster (#51/#54/#56/#57);
subscriptions/blocks/DM-send/reactions are mechanical fast-follows against the same
gate (tracked in TODO #57).
Tests: tests/ensure-signer-decision.test.mjs (9 — capability + recoverable/fail
branching, never-wrong-key), + 2 wiring tests (gate blocks publish until ok) in
tests/profile-modal-controller.test.mjs. Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Switching accounts with the profile modal open (e.g. NIP-07 -> a saved nsec) left
the PREVIOUS account's DMs, subscriptions, hashtags and lists on screen until the
user refreshed. Two problems:
1. handleAuthLogin ran its identity-reset steps unguarded, so if any one threw
(renderSavedProfiles, a warm-list call, etc.) the steps after it were skipped —
including handleActiveDmIdentityChanged, which is what clears the previous
account's conversation. Result: stale DMs after a switch. Each reset step now
runs in isolation via runIdentityResetStep, and the DM reset runs first.
2. The switch-with-modal-open refresh called populateOpenProfilePanels(), which
only paints from current service state and omits lazily-loaded panes (messages,
watch history). It also fired before the new identity's lists had loaded, with
no reliable refresh afterward. Replaced with refreshOpenPanesForActiveIdentity(),
which re-runs the ACTIVE pane through selectPane() — the same load+populate path
as opening it fresh — so DMs reload, subscriptions/hashtags refetch, etc. It runs
immediately, again once profile/wallet data settles, and once more from the auth
coordinator when the new identity's blocks/subscriptions/hashtags finish loading
(backfill for slow sync). Non-active panes stay lazy and repopulate on select.
Per-user list caches (blocks/subscriptions/hashtags are already keyed per pubkey)
mean the active pane shows cached data instantly on switch, then updates when the
new identity's fetch settles — no manual refresh.
Tests (tests/profile-modal-controller.test.mjs +3): a throwing reset step is
isolated; a throwing DM reset no longer skips the saved-profiles reset; and
refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is
open. Existing profile-modal / switch-coordinator / stored-nsec-switch suites green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
ProfileWalletController and ProfileStorageController each had their own copy of
the sync toggle/restore/overwrite-confirm logic, producing different notifications
and duplicate confirm dialogs. Extract js/ui/profileModal/syncSection.js — one
overwrite-confirm dialog, one status-class mapping, and shared runSyncToggle /
runSyncRestore handlers. Both controllers now delegate, so every syncable item
gets identical toasts + one confirm (conflict-only, via the shared syncConflict).
Intentional divergence preserved: the NWC spending-secret warning stays as the
wallet's preEnableConfirm (a wallet-connect URI can spend), since publishing it
even encrypted warrants a one-time heads-up.
Tests: tests/sync-section.test.mjs (8) — enable/conflict/nothing-to-sync/rollback/
preEnableConfirm/disable/restore, plus wallet-vs-storage produce the same message
shapes. wallet-sync + storage-sync + profile-modal suites green (34).
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]>
Switching to a saved nsec profile silently failed: switchProfile -> requestLogin -> the
nsec provider was called with no passphrase, so it threw "secret-required" and the switch
was caught+logged with no user feedback. It never prompted for the PIN.
Now handleProfileSwitchRequest detects an nsec target and, before switching, prepares the
stored-key unlock:
- evaluateStoredNsecSwitch(meta, target) (pure) gates on a persisted nsec key for THIS
account (via getStoredSessionActorMetadata); errors clearly otherwise ("log in with its
nsec", "different account's key saved").
- showPasswordPrompt (js/ui/promptDialog.js) collects the PIN/passphrase in a styled
dialog (Promise<string|null>, stacks above modals).
- switchProfile(pubkey, { unlockStored: true, passphrase }) — authService.switchProfile
now forwards unlockStored/passphrase to requestLogin, which routes to the nsec
provider's unlockStoredSessionActor to restore the signer and activate the account.
- Wrong PIN -> "Incorrect PIN/passphrase" toast (decrypt-failed).
So NIP-07 -> nsec switching works fluently. NIP-46 switching is unchanged (follow-up to
verify/extend); multiple-nsec accounts still share one stored slot (follow-up).
Tests: tests/stored-nsec-switch.test.mjs (6), tests/password-prompt.test.mjs (6). Build +
lint clean; auth suites 25/25.
Co-Authored-By: Claude Opus 4.8 <[email protected]>