Commit Graph
2690 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 4f56a2bd26 Wallet restore popup: re-offer on login when missing locally (like storage)
The wallet already rode the same login "restore from Nostr" offer as storage,
but a once-per-device "offered" flag suppressed it — so on a device without
your wallet you fell back to the manual Sync button. Now:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lint + build clean.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Syntax + lint + build clean.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 12:48:49 -04:00
thePR0M3TH3ANandClaude Opus 4.8 96228a451b zaps: fix zap totals counting zero — coordinate used kind 0 instead of 30078
The zap write path (zapSplit.js derivePointerTag) built the `a` coordinate
tag straight from `videoEvent.kind`. bitvid's internal video objects don't
always carry the Nostr kind — it can be 0 or undefined — and the inline
builder only rejected non-finite kinds, so a kind-0 object produced a
`0:pubkey:d` coordinate (Number.isFinite(0) === true). This flowed into both
the kind-9734 zap request and the kind-30081 bitvid tally.

The read side (zapTotals / mostZappedFeed) fetches totals by querying
`30078:pubkey:d` via buildVideoAddressPointer, which defaults invalid/zero
kinds to 30078. So the writer emitted `0:...` while the reader queried
`30078:...` — they never matched and every zap counted as zero, even after
a cache clear + refresh. Confirmed against live relays: 4 published bitvid
tallies all carried `0:...` coordinates while the real video events are
kind 30078.

Fix: derivePointerTag now delegates to the same canonical
buildVideoAddressPointer the reader uses, keeping writer and reader
coordinates in lockstep. Adds a regression test driving splitAndZap with
kind 0 / undefined / non-numeric video objects, asserting the zap request's
`a` tag defaults to `30078:pubkey:d`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 09:18:38 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cf82978060 zaps: profile-zap counting + channel "sats zapped" total (#47 — closes the feature)
The store now supports p: (profile/channel) pointers — every zap tagged to a
creator pubkey (direct profile zaps AND zaps to their videos, both carry a `p`
tag), from 9735 receipts + verified tallies:

- pointerKey accepts "p"; new profilePointer(pubkey) helper.
- runBatch batches p: pointers into a #p filter; both folds count #p-tagged
  events (deduped by payment_hash like a:/e:).
- Channel page: a "⚡ N sats zapped" total under the lightning address, wired
  to the store via js/channelZapTotal.js (own module for channelProfile.js's
  size budget), hidden until nonzero.

Completes the D2 video + profile scope; the bitvid-native zap tally feature is
now feature-complete (publish + count + chart + video + profile). Tests:
profilePointer/pointerKey + #p counting in tests/zap-totals.test.mjs. Lint
(size cap ok) + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 00:36:44 -04:00
thePR0M3TH3ANandClaude Opus 4.8 974fdc836d charts: dual-line popularity chart — views (red) + zaps (orange) over time (#47 §5.9)
The per-video Popularity modal now plots a second, orange zaps-over-time line
beside the red views line, with a legend and — new — VISIBLE x-axis date ticks
(the old chart only put the range in the aria-label).

- buildZapSatsTimeSeries: buckets cumulative SATS from 9735 receipts + verified
  bitvid tallies, deduped by payment_hash (same rule as the badge store) so the
  chart and badge agree.
- buildPopularityChartSvg: two token-colored cumulative lines on a shared time
  x-axis, each with its OWN y scale (independent per-series — D5), plus date-tick
  <text> labels. Views = .text-accent, zaps = .text-zap (new --color-zap
  utility). Legend is plain DOM in the modal; headline gains "· N sats zapped".
- js/zapEventsFacade.js: lists a pointer's [9735, ZAP_TALLY_KIND] events via the
  L1 SubscriptionManager for the chart; modal re-renders on onZapTotalsChanged.
- All flag-gated (FEATURE_ZAP_TALLY); the existing views-only chart + its test
  are untouched (buildViewCountChartSvg kept).

Verified live earlier that imports resolve (app builds/runs). Tests:
tests/zap-time-series-chart.test.mjs (5) + existing view-count-chart green.
Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 00:27:00 -04:00
thePR0M3TH3ANandClaude Fable 5 68bdee8da5 playback: fix false "No playable source found." on URL-only videos
A bitvid (kind-30078) video with a perfectly playable hosted URL (verified:
archive.org 200 video/mp4 with byte ranges) failed with "No playable source
found." — the video element's src was never even set.

Root cause: the hosted-URL probe (fetch HEAD, mode:no-cors) runs BEFORE src is
assigned, inside the playback-start window — but URL_PROBE_TIMEOUT_MS (4s) is
LONGER than PLAYBACK_START_TIMEOUT (3s). A slow no-cors HEAD (archive.org, and
any host that doesn't answer HEAD fast) consumed the whole start budget, the
withTimeout wrapper aborted the attempt, and the URL was never loaded. A
`<video>` element plays cross-origin URLs WITHOUT CORS anyway, so the fetch
probe was both unnecessary and actively harmful here.

Fix:
- Skip the probe entirely when there's no magnet (URL-only) — nothing to fail
  over to, so the video element loads the URL directly. This is the reported
  case; verified live: the video now plays (readyState 4, currentTime advancing).
- An ambiguous/error probe no longer VETOES the URL (only a definitively-read
  "bad" status deprioritizes it, and even then only when a magnet fallback
  exists) — the video element + watchdogs are the real arbiter.

Tests: spec-corrected the always-probe assertion to a dual-source case + added
a URL-only skip-probe test (tests/services/playbackService.test.mjs). 17 pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 00:13:14 -04:00
thePR0M3TH3ANandClaude Fable 5 5a7481e30e zaps: reliable coinos platform address + fix NWC double-pay on timeout
Two things:

1. Platform fee address → [email protected] (verified live:
   allowsNostr + nostrPubkey, 1-sat min, working callback). coinos is
   Nostr-native and PUBLISHES zap receipts, unlike strike.me whose flaky LNURL
   + never-published receipts caused the platform-share pay timeouts and the
   missing zap tallies. The 30% platform fee is unchanged — only the rail.

2. Double-pay guard (bug found while diagnosing): on a `pay_invoice` timeout,
   nwcClient's nip44->nip04 encryption-fallback resent the request — but a
   timeout is ambiguous (the wallet may already be paying), so the resend
   could DOUBLE-SPEND. shouldRetryWithFallback now refuses to auto-resend any
   spending method (pay_invoice/pay_keysend/multi_pay_*) on timeout; read-only
   methods and explicit unsupported-encryption rejections (wallet never paid)
   stay retryable. The manual "Send again" retry is the safe path for a real
   payment timeout.

Tests: tests/nwc-pay-timeout-no-double-spend.test.mjs (4). Lint + build green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:54:16 -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 025ab3487d zaps: count bitvid tallies in the zap-totals store (#47)
Second slice of docs/zap-tally-plan.md §4. runBatch now queries kinds
[9735, ZAP_TALLY_KIND] in one call and folds both sources:

- 9735 receipts: unchanged sum, but now also records each receipt's
  payment_hash (from its bolt11) for cross-source dedup.
- bitvid tallies: verifyBitvidZapTally (preimage + description-hash bound),
  amount from the bolt11, pointers from the embedded zap request; counted
  once per payment_hash so a payment that yields BOTH a 9735 and a tally is
  never double-counted.

Entries gain a paymentHashes set. Counting is gated by isTallyEnabled
(singleton reads FEATURE_ZAP_TALLY live). The existing "relay wins" prune
clears the optimistic/ledger overlay once a real tally or receipt lands, so
the sender's own published tally reconciles the instant bump with no extra
plumbing.

Tests: cross-source/same-payment dedup, invalid-tally rejection, flag gating
(tests/zap-totals.test.mjs). Lint + build green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:35:51 -04:00
thePR0M3TH3ANandClaude Fable 5 a6b2996f4e zaps: foundation for the bitvid-native zap tally (#47) — schema + verify
First slice of docs/zap-tally-plan.md (decisions D1-D5 resolved in the doc):

- ZAP_TALLY_KIND = 30081 (addressable, d=payment_hash → one canonical event
  per real payment) + NOTE_TYPES.ZAP_TALLY schema + buildZapTallyEvent
  (nostrEventSchemas.js). Video + profile pointers.
- FEATURE_ZAP_TALLY flag (constants.js), default ON per D4.
- zapReceiptValidator.js: extractBolt11Fields (payment_hash + description_hash
  from the existing bech32 walker), verifyPaymentPreimage
  (sha256(preimage)==payment_hash), verifyBitvidZapTally — the security core:
  proves the invoice was paid AND bound to this zap request (description-hash
  binding blocks reusing a leaked preimage for another video). Amount is
  bolt11-derived, never the tag; pointers come from the embedded zap request.
- Schema-doc row for kind 30081.

Tests: tests/zap-tally-verify.test.mjs (8) — preimage check, bech32 field
round-trip, valid/bad-preimage/replay/zero-sats decisions, builder tag shape
(video + profile). Lint + build green.

No publishing or counting wired yet — next slices per the plan checklist.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 23:32:12 -04:00
thePR0M3TH3ANandClaude Fable 5 cbc62306a4 zaps: durably persist sent zaps so they survive reload + rank on Most Zapped
Root cause the user hit: NWC doesn't publish zap receipts, and neither does
bitvid — per NIP-57 the recipient's LNURL server publishes the kind-9735
receipt. Custodial providers (Strike) routinely don't, so a paid zap leaves
NO on-relay record to count, and the in-memory optimistic bump vanished on
reload → zapped videos showed 0 and never rose on Most Zapped.

Add a durable local ledger (localStorage bitvid:sentZaps:v1) of the user's
OWN sent zaps, seeded into the store's optimistic layer at construction so
the badge + Most-Zapped rank survive reloads with no receipt. Reconciliation:
when a real 9735 receipt for a pointer later appears on relays, the relay
becomes authoritative and that pointer's ledger entry is pruned, so a
receipt-publishing recipient never double-counts. ingestLocalZap writes the
ledger; runBatch prunes it.

Live-verified: card badge 0 -> 4.3K sats on send, still 4.3K after a full
page reload, ledger present in localStorage. Tests cover persist-across-
reload and prune-on-real-receipt.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 15:05:01 -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 213cb97ee8 modal: fix video player failing to open — missing-delegate TypeError on every open
Root cause of "clicking a video card no longer opens the player":
coordinator methods run with `this` bound to the Application
(bindCoordinator), and the zap-badge helpers were added as coordinator
methods called via `this.subscribeModalZapTotal(...)` — but app.js only
exposes coordinator methods through explicit delegates, which were never
added. Every modal open threw "this.subscribeModalZapTotal is not a
function" inside subscribeModalViewCount (and every close threw in
teardown). Same delegate landmine that broke Most Zapped's loader.

The helpers are now plain closures inside createModalCoordinator — immune
to the delegate pattern by construction. Regression test drives the REAL
bindCoordinator with only app.js's actual delegates, so any future
cross-method `this.` call without a delegate fails in CI first.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 10:45:50 -04:00
thePR0M3TH3ANandClaude Fable 5 941580f227 feeds: fix Most Zapped eternal spinner — missing app-level delegates
app.js delegates each feedCoordinator method explicitly (the coordinator is
lazy), and registerMostZappedFeed / loadMostZappedVideos were never added.
Bootstrap's `typeof app.registerMostZappedFeed === "function"` guard was
silently false (feed never registered) and the view registry's
loadMostZappedVideos call no-oped, so the view's spinner was never replaced.
Reproduced in a Playwright harness (spinner before, 158 videos rendered
after adding the delegates).

Also exposes window.__bitvidZapTotals (snapshot/request/flush) for
diagnosing zap-badge visibility in live sessions.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 09:34:44 -04:00
thePR0M3TH3ANandClaude Fable 5 cbe894969d zaps: fix badge never showing (array pointers) + zap total in the player modal
The zap-totals store's pointerKey only understood {type,value} objects, but
deriveVideoPointerInfo produces tag-style ARRAYS (["a", value, relay?]) — so
every card-badge lookup keyed to nothing, no receipt fetch was ever
scheduled, and the Most Zapped ranking metric silently read 0 for every
video. pointerKey now normalizes both shapes; tests use the array shape the
app actually passes.

Also adds the orange sats badge to the video player modal's meta row
(right side via the row's existing justify-between; views stay left),
driven by the same cache/change-signal as the cards and torn down with the
view-count subscription.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 09:20:11 -04:00
thePR0M3TH3ANandClaude Fable 5 ad62205afd zaps: retry transient LNURL failures once before failing the zap
Verified live against strike.me: the SAME lnurlp endpoint returned
{status:"ERROR","reason":"Could not get user information"} on one request
and a valid callback moments later — the address was fine, the server
blipped. fetchPayServiceData now retries once (600ms) on the transient
classes (fetch rejection, non-OK HTTP, non-JSON body, ERROR-status body);
shape-invalid payloads still fail immediately, and a persistent error
surfaces the server's reason after the single retry.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 09:02:27 -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