Commit Graph
850 Commits
Author SHA1 Message Date
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 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
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
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 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 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 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 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 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 Fable 5 afed041f9a zaps: retried shares also publish tallies + bump the badge (#47)
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]>
2026-07-04 23:41:25 -04:00
thePR0M3TH3ANandClaude Fable 5 41e533e675 zaps: publish preimage-verified tallies on successful zap (#47)
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]>
2026-07-04 23:38:44 -04:00
thePR0M3TH3ANandClaude Fable 5 af09505fb3 zaps: optimistic zap-total bump so the badge updates instantly on send
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]>
2026-07-04 14:55:14 -04:00
thePR0M3TH3ANandClaude Fable 5 8d45f25962 zaps: make the card + modal zap counter always visible (0 sats when none)
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]>
2026-07-04 14:43:22 -04:00
thePR0M3TH3ANandClaude Fable 5 e2d4251035 sync/zaps: enabled devices auto-pull newer wallet/storage notes at login; zap errors name the failing address (#15 follow-up)
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]>
2026-07-04 08:56:05 -04:00
thePR0M3TH3ANandClaude Fable 5 86d7db4b1a cards: orange right-aligned zap-total badge next to view counts (#47 follow-up)
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]>
2026-07-03 21:49:35 -04:00
thePR0M3TH3ANandClaude Fable 5 8dfd2034b0 feeds: "Most Zapped" sidebar tab ranked by zap totals (#47)
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]>
2026-07-03 21:38:53 -04:00
thePR0M3TH3ANandClaude Fable 5 35a50611fe upload: in-modal "Upload destination" storage picker (#44)
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]>
2026-07-03 21:24:13 -04:00
thePR0M3TH3ANandClaude Fable 5 d1a578f3a2 profile modal: always-visible "Acting as" identity card (#43)
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]>
2026-07-03 21:24:13 -04:00
thePR0M3TH3ANandClaude Fable 5 1934f9386a publish: all-timeout content publishes are unconfirmed soft successes (#49)
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]>
2026-07-03 21:01:31 -04:00
thePR0M3TH3ANandClaude Fable 5 e32298cc89 uploads: locked-storage file picks unlock inline instead of dead-ending (#56)
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]>
2026-07-03 21:01:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 9afbc0cbbb [search] Decode npub authors to hex — the Author filter matched nothing (npubs)
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]>
2026-07-02 23:12:20 -04:00
thePR0M3TH3ANandClaude Opus 4.8 4591d4a260 [onboarding] Fix popover pushed off-screen by full-height targets (sidebar step)
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]>
2026-07-02 22:50:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 8e7c6bc325 [onboarding] First-run guided tour with spotlight + accent glow (#59, phase 1)
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]>
2026-07-02 22:44:45 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5466d97544 [ui] Remove NIP-71 authoring sections from upload/edit modals (#58)
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]>
2026-07-02 15:18:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6e2894e3d8 [video-modal] Fix dead like/dislike buttons — dispatch video:reaction (#54)
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]>
2026-07-02 09:40:46 -04:00
thePR0M3TH3ANandClaude Opus 4.8 757fca1059 [edit] Host the .torrent (xs=) for edited external URLs too — close the gap
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]>
2026-07-02 09:17:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 11c7884366 [edit] Derive a webseed torrent when editing a video's URL to an external link
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]>
2026-07-02 09:12:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cf6a830183 [upload] Derive a webseed torrent for external-URL posts (P2P for external links)
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]>
2026-07-02 09:08:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a20388c5d6 [video-modal] Fix dead channel/creator link — event-name mismatch (#53)
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]>
2026-07-02 08:51:10 -04:00
thePR0M3TH3ANandClaude Opus 4.8 3df398d7ad [upload] Suggest one-tap hashtag chips from the user's past videos (#45)
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]>
2026-07-01 20:59:56 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6bdf28fb30 [upload] Auto-unlock storage in the upload modal for a kept-unlocked session (#51)
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]>
2026-07-01 20:20:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cebd3dc1e7 [auth] Add "Lock this device" control to re-lock a kept-unlocked session (#51)
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]>
2026-07-01 20:16:00 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b17bb8fcc [auth] Opt-in "keep unlocked" so a reload doesn't re-prompt for the PIN (#51)
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]>
2026-07-01 19:51:41 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1622e85d10 [auth] Route reactions, blocks, subscriptions & DM send through the signer gate (#54/#57)
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]>
2026-07-01 19:23:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b79ab9d09 [auth] Re-unlock the signer on demand instead of blanket-erroring encryption ops (#57)
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]>
2026-07-01 19:11:19 -04:00
thePR0M3TH3ANandClaude Opus 4.8 87af650b38 [profile] Refresh the profile modal on account switch (no manual reload)
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]>
2026-07-01 16:31:01 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a59a74c976 [sync] Unify wallet + storage sync UI: one confirm, consistent notifications (step 2)
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]>
2026-07-01 16:04:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cf4ce5c3b8 [admin] Add sub-tab toolbar + Blocked-videos tool to the admin pane (#25 follow-up)
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]>
2026-07-01 10:56:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 fb2f12b49d [auth] #48: fluent switch to a saved nsec account (prompt for PIN + unlock stored key)
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]>
2026-06-30 22:11:43 -04:00