Same encrypted-replaceable-list data-safety pattern as the Stash fix, applied
to usePrivateContacts (kind 30000, NIP-44 encrypted to self).
add/remove built the new list from the React Query cache, which is empty until
the Contacts page has decrypted — and with Amber the read query is gated /
staleTime:Infinity. Saving a contact before that decrypt (fresh device, or if a
"save privately" action is ever wired up elsewhere) could publish a list with
only the new pubkey and silently wipe every other private contact.
Fix: add/remove now call fetchLatest() — a fresh relay fetch + decrypt of the
current list immediately before publishing — and union it with the warm cache
so an optimistic just-added pubkey is never dropped. On fetch/decrypt failure
it falls back to the cache rather than risk a wipe.
UX: clarified the contacts search placeholder ("Paste an npub to filter…") so
it no longer implies name search that the npub-only filter doesn't do. Left the
star toggle un-confirmed on purpose — it's a low-stakes, one-tap-reversible
action and a dialog would only add friction. ContactsPage is otherwise already
clean/elegant (tabbed, skeletons, thoughtful empty states, encryption notice).
Bumps to 1.2.24 (Android versionCode 10224 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audited the Stash — a private, NIP-44-encrypted, Amber-sensitive surface.
DATA-SAFETY FIX (the important one):
The stash is a single replaceable event whose encrypted content is the WHOLE
item list, so every add/update/remove rewrites the entire event. The mutations
built the new list from the `stash` closure value — but that's legitimately
`undefined` before the decrypt completes, and with Amber the read query is
gated / staleTime:Infinity, so it may never have been decrypted at mutation
time. Result: adding one item while `stash` was empty/undefined could publish a
list containing only that item and silently WIPE all existing gear.
Fix: mutations now call `fetchLatestStash()` — a fresh relay fetch + decrypt of
the current stash immediately before saving — and build the new list on top of
that. It falls back to React Query cache (never an empty list) on failure, and
throws rather than risk a clobber if it truly can't load. saveStash also writes
the just-published value straight into the cache to keep reads consistent.
The extra decrypt per mutation is an acceptable Amber tradeoff: a mutation is
already a deliberate write (encrypt + sign prompt), and data integrity beats
prompt count.
UX:
• Delete now asks for confirmation via an AlertDialog (item name + a note that
public items get unlisted). Previously a single tap wiped an item outright —
destructive and, with Amber, a surprise signer prompt.
The rest of StashPage (login gate, encryption-required state, distinct
wishlist/stash/filtered empty states, skeletons, rarity cards, grid/list) is
already excellent — left as-is.
Bumps to 1.2.23 (Android versionCode 10223 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Continued the wallet audit with a focus on intuitive, minimal, elegant UX.
• Recovery phrase — replaced the raw browser alert() (which dumped the seed
as unstyled, uncopyable plaintext) with a proper RecoveryPhraseDialog:
hidden/blurred by default with a deliberate "tap to reveal" gate (no
shoulder-surfing on open), a numbered 12-word chip grid for accurate
transcription, a copy button (enabled only after reveal), and an honest
"paper is safest, clipboards leak" note. Re-hides on every close. This was
the wallet's most sensitive surface and its worst blemish.
• Fixed shared-state bug: "Create Cashu Token" and "Pay Lightning Address"
both wrote to the same `sendAmount`, so switching between them carried a
stale amount across two unrelated intents. The address form now owns its
own amount state.
• Added loading feedback + double-tap protection to every money action that
lacked it — Generate Invoice ("Generating…"), Create Token ("Creating…"),
Receive Token ("Receiving…"), Pay Lightning Address ("Sending…"). Each hook
now exposes an in-flight flag and guards re-entry.
WalletPage and the browser "app-only" notice were already clean/elegant —
left as-is.
Bumps to 1.2.22 (Android versionCode 10222 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audit of the wallet/zap flow surfaced a real money-path bug in ZapDialog's
Cashu branch:
• One-click Cashu zap never worked. The Cashu payment branch only ran
`if (... && invoice)`, but no invoice exists on the first click — so
selecting "Cashu" and tapping Zap silently fell through to the Lightning
flow (generating an invoice / paying via NWC-WebLN) and the user's Cashu
balance was never used. Now the Cashu path generates the recipient's
amount-verified zap invoice and melts it from the Cashu wallet in a single
click.
To support this cleanly, useZaps now exposes `generateZapInvoice()` —
builds + signs the NIP-57 request, fetches the LNURL invoice, and runs the
same `assertInvoiceAmount` security check the Lightning rail uses — WITHOUT
auto-paying. The Lightning `zap()` path was refactored to use it too, so
both rails share one verified-invoice codepath.
• Custom-amount balance guard bypassed. The insufficient-balance warning and
the disabled Send state only checked `typeof amount === 'number'`, but the
custom-amount input stores a string — so a typed amount over the Cashu
balance slipped past both. Both now coerce to a number first.
• Smarter defaults: when the dialog opens and the user has Cashu funds but no
connected Lightning wallet, Cashu is pre-selected and the "connect a wallet"
setup nag is skipped (they can already pay).
• Removed a dead `useLightningOperations` import.
Also audited mobile layout (Layout.tsx — mature, safe-area-aware, no changes
needed) and ZapCrewDialog (uses the separate useCrewZap splitter, no Cashu
option, no bug).
Bumps to 1.2.21 (Android versionCode 10221 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audit outcome for the two suspected "double-prompt" Amber flows:
• Image upload — CONFIRMED already a single signature. BlossomUploader
(even at the pinned 0.47.1) signs one kind-24242 auth event and reuses
it across all servers via Promise.any, so uploads = 1 Amber prompt. No
change needed.
• DM / crew-chat send — the multiple prompts are protocol-inherent and
MUST NOT be optimized away: NIP-17 seals+signs once per recipient (and
to self); a Cordn crew message signs an author proof then encrypts once
per roster member. Reducing the count would weaken confidentiality or
authorship. So instead of a code shortcut we add transparency.
New <AmberApprovalHint>: a tiny heads-up shown ONLY to interactive
(Amber) signers before an inherently multi-approval action, telling them
how many Approve screens to expect and that it's normal. Renders nothing
for silent signers (extension/nsec/bunker), where the ops are invisible.
Wired into:
• CrewChat composer — count derived from transport + roster size
(Cordn: roster+1; NIP-17: roster*2).
• ConversationPage (1:1 DM) composer — 4 approvals (encrypt+sign to the
recipient and to yourself).
Dependencies: reviewed the full manifest; deliberately left unchanged.
Every upgrade candidate is either on the critical crypto/Nostr/payments
path (nostrify, nostr-tools, cashu, bitcoinjs, noble, scure — pinned for
safety) or a documented major bump (lucide v1, react 19, react-router 7)
with breaking changes and no user-facing benefit. No security advisories
force a change, and caret ranges already pull safe patch/minor updates.
"It must work" wins over churn.
Bumps to 1.2.20 (Android versionCode 10220 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Accepting/declining a crew invite (and handling join-requests) fired an
encrypted NIP-78 cross-device sync on every dismissal, costing an extra
encrypt + sign Amber approval on top of the required membership signature.
For interactive (Amber) signers, dismissal is now kept local-only:
• Accept invite → 1 Amber prompt (membership attestation only)
• Decline invite → 0 Amber prompts (local dismissal only)
The dismissal still applies instantly and permanently on-device; only the
cross-device "clear it on my other devices too" nicety is dropped for Amber.
Extension/nsec/bunker signers keep the silent encrypted sync (free for them).
Bumps to 1.2.19 (Android versionCode 10219 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Added <ImageWithFallback> — a drop-in <img> that shows a subtle placeholder
(skate emoji or a caller-supplied icon) instead of the browser's broken-image
glyph when a user-supplied URL is missing or 404s. Fills the same layout box so
nothing shifts.
Swapped it into the content-image surfaces that display user-uploaded URLs:
- Marketplace item cards (grid + purchase dialog)
- Spot detail (hero + gallery thumbs) and map list/popup cards
- Shop detail (hero + gallery)
- Crew & Lab cross-link cards (icon fallback)
- Crew agent panel, newsletter issue view + notification bell newsletter thumb
- Stash gear cards (list + grid), profile banner (NIP19), newsletter spot cards
Left untouched: avatars (shadcn Avatar already has fallbacks), QR codes and
rider-card/print images (data URLs, never 404), and in-form upload previews
(local object URLs).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three places used window.location.href to navigate to bare paths (/signers,
/messages/:npub, /settings). Under HashRouter those trigger a full page reload
to a non-hash path the router can't match — the user got a blank/404 flash
instead of the target screen.
- WhosSkating: DM button now uses useNavigate() instead of a reload.
- StashPage: "Learn About Signers" now uses <Link to="/signers"> (asChild).
- BackupReminder: "Back up now" now uses navigate() AND corrects the deep-link
param from ?tab=security to ?section=security (SettingsPage reads ?section),
which previously dropped the user on the settings root instead of Security.
Also closes the reminder sheet on navigate.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
usePersonalContext (consumed by SasquatchGuide, mounted in the app-wide Layout)
eagerly called useStash(), firing the encrypted-stash NIP-44 decrypt on EVERY
page load — one Amber popup per navigation. useStash now accepts { enabled },
and usePersonalContext passes enabled:false so it only reads the stash from
cache (populated when the user actually opens the Stash page). The AI's
getMyStash tool degrades gracefully to cached data. No behaviour change for
silent signers.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
With Amber as signer, every nip44 decrypt is a foreground intent (visible
popup). Several hooks decrypt inside auto-running queries, so Amber flashed on
navigation/focus and (worst case) storms — one popup per encrypted event, and
polling queries re-fired every few seconds.
New helper isInteractiveSigner() (signerCapabilities.ts) detects the Amber
(x-amber) signer, where each crypto op prompts the user. Hooks now gate passive
decryption for interactive signers only; extension/nsec/bunker keep their
silent, live-polling behaviour.
Fixes:
- useCrewDismissals (GLOBAL via header NotificationBell — the main offender):
Amber users now use the instant localStorage mirror only, never passively
decrypt the synced list; disabled refetchOnWindowFocus + Infinity staleTime.
Dismissals still sync OUT on the write path.
- useDirectMessages (all 4 DM query hooks): added amberSafeQueryOptions() —
no background polling and no focus refetch for Amber (these decrypt up to
~200 gift wraps × 2 each); silent signers keep the 60s poll.
- useStash / usePrivateContacts: disabled focus refetch + Infinity staleTime
for Amber so the single decrypt runs once, not on every focus.
- useCordnChat: dropped the 5s live poll for Amber (was a popup every 5s);
silent signers keep it.
Scanned every signEvent call site: all are inside mutations/user callbacks
(zaps, invites, DMs, chat, follows, purchases, NIP-98 auth) — no passive
auto-signing anywhere. Deliberate user actions still prompt Amber as intended.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause: the stock nostr-signer-capacitor-plugin (0.0.5) tries Amber's
background ContentProvider first, which GrapheneOS blocks and never returns,
with no clean fallback — so signing hung 90s and Amber never appeared.
Fix (matches Amethyst, the reference NIP-55 client):
- New native Capacitor plugin AmberIntent (android-templates/AmberIntentPlugin.java)
that goes STRAIGHT to a foreground nostrsigner: intent via a properly-registered
ActivityResultLauncher, tracks each request by id, and times out gracefully.
- MainActivity now registers the launcher in onCreate (AndroidX contract) and
hands it to the plugin, while staying a safe stock BridgeActivity (no
WebViewClient override → no black screen).
- inject-main-activity.mjs now installs both MainActivity.java and AmberIntentPlugin.java.
- capacitorAmberSigner.ts rewired to registerPlugin('AmberIntent'); every op is a
user-visible Amber Approve/Reject popup; rejections surface as a friendly message.
- Removed the dead npm nostr-signer-capacitor-plugin dependency (and its phantom
coracle fork spec) plus its vite external entry — signing no longer uses it.
- Delete flow copy updated to the new foreground UX (Approve in Amber / Cancelled
on reject) instead of the diagnostic SIGN/PUBLISH narration.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Fixes the audit findings:
HIGH-1 (NWC/WebLN auto-pay of unverified invoices): add assertInvoiceAmount and
call it before every programmatic payment in useZaps, useCrewZap, and
SpamFeePayment. A malicious LNURL/Lightning-address server can no longer return
an inflated invoice and drain the wallet — amount is decoded from bolt11 and must
match the intended sats (±1 sat), amountless invoices are rejected.
HIGH-2 (forgeable trust signals):
- useSasquatchPro: Pro is granted ONLY by a zap receipt AUTHORED by the
platform's Lightning provider, embedding a user-signed zap request that tags
the platform, with a real paid amount >= smallest tier. The self-signed
kind:32671 receipt is now advisory only and never grants access (closed the
"self-grant permanent Pro" hole).
- useFeaturedVerification: a listing is 'verified' only when the matching 9735 is
authored by the provider pubkey (not any self-published receipt).
- useCrewGoal: tally only invoice-backed (bolt11) zaps; the forgeable amount tag
is no longer summed, so goal progress can't be inflated with fake receipts.
Bump to 1.2.14.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
We can't identify where delete freezes. Split the flow into two independently
timed, on-screen-narrated phases: Step 1 SIGN (Amber) and Step 2 PUBLISH
(relays). Whichever toast is on screen when it hangs tells us exactly which layer
is at fault — Amber vs relay — so we fix the right thing. Bump to 1.2.13.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Admin delete fired THREE signed events (kind:5 deletion, kind:1985 label, NIP-86
banevent). Each opens Amber, but Amber handles one approval at a time — the
extra background signs collided with the primary one on Android and hung the
whole delete ("taking longer than expected"). Now the admin path publishes only
the kind:5 deletion (one Amber approval) and hides locally (no signature); the
optional label/relay-ban are dropped from the interactive flow. Also raise the
awaited timeout 40s→120s to fit a real Amber round-trip. Bump to 1.2.12.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The manifest launches org.skatespots.app.MainActivity; when that class was
missing/wrong the app crashed on startup. Add a STOCK MainActivity (extends
BridgeActivity, no WebViewClient override) plus an idempotent injector that
guarantees it's always present after cap add / rm -rf android. This is the safe
default — the earlier custom version broke startup. Delete fix (setPackageName)
remains intact. Bump to 1.2.11.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The injected MainActivity overrode Capacitor's WebViewClient, wiping out the
client Capacitor needs to load the web layer — causing a black-screen crash on
launch. Remove the MainActivity template, its injection script, and the build
wiring. The real signing fix (setPackageName before signEvent) is unaffected and
remains in place. Bump to 1.2.10.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Verified against the plugin's Java source: signEvent/nip*Encrypt/Decrypt
IMMEDIATELY reject with "Signer package name not set" when no package is
configured — which is exactly what my earlier "omit packageName" change caused,
and that rejection surfaced as the endless spinner on delete/accept/zap.
With the package set, the plugin tries the content resolver, and when that
returns null (permission not remembered) it falls back to
startActivityForResult(nostrsigner:) — the SAME native call login uses that
reliably opens Amber. Restore native plugin signEvent/nip04/nip44 with
setPackageName + AMBER_PACKAGE, reconstruct signature-only responses, and remove
the unused JS-intent bridge. Bump to 1.2.9.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Amber never appeared for delete/accept-invite/zap because Capacitor's default
WebView silently drops JS-initiated intent:/nostrsigner: navigations — the
request that opens Amber never reached the OS. Login worked only because it
uses the plugin's native startActivityForResult.
Add a custom MainActivity (android-templates/MainActivity.java) that overrides
shouldOverrideUrlLoading to launch intent:/nostrsigner: URLs via startActivity()
— a real native launch that opens Amber on GrapheneOS, matching login. Injected
into the generated android/ project at build time by inject-main-activity.mjs,
wired into android:build/sync/init. Simplify the JS launcher to navigate the
WebView (now intercepted natively). Bump to 1.2.8.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The capacitor plugin's signEvent/encrypt query Amber's ContentProvider first,
which stalls on GrapheneOS and never launches Amber's approval UI — causing
delete, accept-invite, zaps, and every signing action to hang forever.
Add amberIntentBridge.ts which drives Amber through the reliable
intent:...scheme=nostrsigner flow (the same path login uses that actually opens
Amber), returning a promise resolved in-process via the org.skatespots.app://
deep-link callback (DeepLinkHandler → resolveAmberIntent). CapacitorAmberSigner
now routes signEvent/nip04/nip44 through this bridge instead of the stalling
ContentResolver path. Fast 60s fail-safe with a clear message instead of an
indefinite spinner.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The likely real cause of "Amber never pops up, delete hangs" on
GrapheneOS: login only requested the get_public_key permission, so later
sign_event / encrypt / decrypt calls hit a per-action permission check
that routes through Amber's ContentProvider and stalls WITHOUT showing a
prompt. Now login pre-grants get_public_key, sign_event, nip04/nip44
encrypt+decrypt up front, so subsequent signing is a fast background
round-trip with no surprise permission stall.
IMPORTANT: existing sessions only granted get_public_key — users must log
out and back in once for Amber to grant the new permission set.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The badge counted ALL actionable crew items (invites, join requests,
pending confirmations) regardless of read state, so "Mark read" (and even
opening the bell) never cleared it. Now the badge counts only items newer
than a per-account "seen at" timestamp that "Mark read"/open updates.
Actionable crew cards still render in their tab until acted upon — only
the red badge clears — which is the expected behavior.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
ROOT CAUSE of "stuck on Deleting…" with no Amber prompt:
signEvent (and nip04/nip44 encrypt/decrypt) passed packageName to the
native plugin. With a packageName set, the plugin first tries Amber's
ContentProvider — a background, no-UI query that stalls on GrapheneOS and
never launches Amber's approval screen. So the user saw NO prompt and the
request timed out ("takedown taking longer than expected").
Login already worked around this by omitting packageName so the plugin
goes straight to startActivityForResult(nostrsigner:) which shows Amber's
UI. This mirrors that fix for every signing/crypto op: use the raw native
signer with NO packageName. Removed the now-unused getPlugin /
ensurePackageName / setPackageName machinery.
This is why deletes (and any signed action / encryption) never surfaced
Amber and hung.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Pinning exact versions produced a broken @nostrify/nostrify variant on
some installs (raw .ts source missing @std/encoding / websocket-ts deps).
Revert to caret ranges so npm resolves the complete, working tree.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The community relay goskatespots.nostr1.com runs on relay.tools, which
implements the NIP-86 Relay Management API. As the relay owner/moderator,
an admin can genuinely remove an event from the relay's database via
`banevent` — unlike a NIP-09 kind:5 request that most relays ignore for
non-authors.
- New src/lib/relayAdmin.ts: NIP-86 JSON-RPC client with NIP-98 HTTP auth
(kind:27235 signed by the user's signer, payload hash included per
NIP-86). Includes banEventOnRelay + getRelaySupportedMethods, with a
CORS-proxy fallback for WebView contexts.
- useDeleteEntity admin path now also fires banEventOnRelay in the
background (best-effort, never blocks the spinner), so admin takedowns
actually erase the event from our own relay in addition to the kind:5
request, optimistic local hide, and cross-device kind:1985 label.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
DELETE HANG (real fix):
The admin takedown required TWO sequential Amber approvals (kind:5
deletion + kind:1985 hide label) and awaited both. On mobile the second
Amber prompt often doesn't resurface, so the button sat on "Deleting…"
until the 90s signer timeout. Now:
- Only the kind:5 deletion is awaited (the required action).
- The target is recorded in a local optimistic-hide store immediately, so
the app hides it on this device the instant kind:5 succeeds — no waiting
on a second signature.
- The cross-device kind:1985 label is fired in the BACKGROUND (not
awaited); a second approval, if needed, happens after the dialog closes.
- The whole mutation is wrapped in a 40s hard timeout so the spinner can
never hang indefinitely, whatever the signer/relay does.
- useAdminModeration merges the local optimistic set so hides are instant
and survive reload.
NOTIFICATION BELL UI:
- Brand-lime (#ccff00) glowing badge with ring, replacing the flat teal dot.
- Bell wiggles + soft glow pulse when there are unread items (new `wiggle`
keyframe).
- Gradient header with an icon chip, inline count, and a pill "Mark read"
button / "All clear" state.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Newer nostr-tools removed nip57.getSatoshisAmountFromBolt11, breaking
useZaps and useCrewGoal. Add src/lib/bolt11.ts (getSatoshisFromBolt11)
that parses the sat amount straight from the invoice's human-readable
prefix — no external dependency, immune to nostr-tools API churn — and
use it in both hooks.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The clean node_modules reinstall pulled newer @nostrify/* and nostr-tools
minors whose nip57.makeZapRequest signature changed (event param became a
required full NostrEvent), breaking tsc in useZaps and usePurchasePro.
Build the kind:9734 zap request events directly per NIP-57 instead of
relying on nip57.makeZapRequest, so we're decoupled from that helper's
changing type signature. Preserves correct target tagging:
- addressable events → 'a' tag (kind:pubkey:dTag)
- other events → 'e' tag (event id)
- profile-only (pro purchase) → no event tag, keeps pro-tier/pro-expiry.
Pin @nostrify/nostrify, @nostrify/react and nostr-tools so the tree
stays reproducible.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Extends the admin-delete hang fix across the whole app. Audited every
signing route:
- Native (CapacitorAmberSigner): already fixed — computes id locally.
- Web/PWA (AmberSigner redirect flow): the callback handlers did a raw
JSON.parse(result), but Amber returns a BARE signature (returnType=
'signature'), so this threw or produced an event with an empty id that
stalls the relay — the same "stuck" defect as native. Added a shared
reconstructSignedEvent() helper that rebuilds the event from the stored
template + signature and computes the id via getEventHash. Applied in
both AmberCallbackPage and AmberCallbackHandler.
- NIP-17 DMs, zap requests, vouches, crew invites, membership, etc. all
sign via user.signer.signEvent and therefore inherit the fix.
- nsec / bunker / extension signers use Nostrify base classes (compute
ids internally) — unaffected.
- Native nip04/nip44 encrypt/decrypt are timeout-protected — no hang.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Compute event id locally in CapacitorAmberSigner.signEvent instead of
relying on Amber to echo it back. Amber (NIP-55) commonly returns only a
bare `signature` for kind:5 deletions and kind:1985 moderation labels,
leaving id empty — which failed the malformed-event check or emitted a
bad event that stalled the relay forever (the "stuck on Deleting…"
symptom). The id is a deterministic NIP-01 hash of the signed fields, so
we derive it ourselves, making signature-only responses work for every
kind including admin takedowns.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
#2 (crash): useDirectMessages called getEventHash() without importing it —
`getEventHash is not defined` broke crew chat and any NIP-17 DM send. Added
the missing import from nostr-tools.
#3 (bell not loading on phone): the notifications popover had a fixed-height
scroll area and no collision handling, so on small screens it rendered clipped
against the top edge with the tab content cut off. The popover now constrains
to the Radix available-height, uses a flex column, and the tab list/scroll area
flex correctly — all tabs (incl. Crews/News) are reachable on mobile.
#4 (multi-account on phone): the mobile header user menu had only Set Status +
Log Out. Added an Accounts section that lists other logged-in npubs to switch
between, plus "Add another account" which opens the login dialog. Desktop
already had this via AccountSwitcher; mobile now reaches parity.
Note: #1/#5/#6 (stuck on publish/vouch/delete) were reported against the shipped
1.2.2 APK, which also contained the #2 crash. Rebuild 1.2.3 and re-test — the
sign/publish paths in current source are correct (Amber round-trip has a 90s
timeout + surfaced errors).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two follow-ups to the Nostr-native newsletter (skipped auto-DM by design —
too spammy/fragile for now; the bell mailbox already covers the inbox feel).
Subscriber count (social proof):
- useNewsletterSubscriberCount tallies public NIP-78 subscription markers
(deduped per author, counting current "true" state).
- Shown under the subscribe CTA as "Join N+ skaters getting the drops",
hidden until it's meaningful (>=3) and rounded once it grows.
Archive backfill (consistency without losing the rich page):
- lib/newsletterArticle.ts converts a static NEWSLETTER_ISSUES entry into a
NIP-23 (kind 30023) article — structured sections rendered to Markdown,
stable slug `issue-N`, published_at from the issue date.
- NewsletterBackfillPanel (admin-only) diffs the static archive against what's
already on-relay and publishes only the missing issues; safe to re-run,
reports partial failures. The rich static page rendering is untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Turn the static newsletter into a live, Nostr-native marketing channel that
npubs can subscribe to and receive in-app.
Publishing (admins):
- Newsletter issues are published as NIP-23 long-form articles (kind 30023)
tagged `skatespotsnews`, on the community relay.
- NewsletterComposer: admin-only dialog to write + publish an issue (title,
slug, summary, cover image, Markdown body).
Subscribing (any npub):
- useNewsletterSubscription: per-user opt-in stored as a NIP-78 (kind 30078)
marker, so it syncs across every device the user logs into.
- NewsletterSubscribeButton on the /newsletter page and issue reader.
Mailbox / notifications:
- New "News" tab in the NotificationBell showing all issues, with an unread
badge for subscribers (tracked via a per-account last-read timestamp).
Latest unread drop also surfaces in the "All" tab.
- Opening the bell or the /newsletter page clears the News badge.
Reading:
- useNewsletterIssues: queries admin-authored issues (author-scoped + tag +
admin re-validation as the trust boundary against spam).
- LiveIssuesSection on /newsletter shows issue cards linking to a reader.
- NewsletterIssueView: naddr reader (wired into NIP19Page for kind 30023) with
a small safe Markdown renderer, zap + subscribe CTAs.
Docs: NIP.md documents the 30023 issue schema, the 30078 subscription marker,
and the admin trust boundary.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Point CREW_AGENT_AVATAR at the intended Sasquatch artwork. Since it's the
single source of truth, this updates the agent's image everywhere (panel
header/rows/empty state, crew page button, CrewForm toggle) and in the
agent's published kind-0 profile.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Sasquatch now knows the crew it lives on. The agent is seeded with a rich
crew dossier — its own upcoming events, recent feed digest, roster size,
founder, open call, vibe, band, home spots, website/IG — and the system
prompt instructs it to answer crew-specific questions ("what's our next
jam?", "who runs this?", "what are we looking for?") directly from memory,
while still using CVM tools for wider-network discovery.
- useCrewAgentChat: expanded CrewContext + crew dossier + two-mode prompt
- CrewDetailPage: wires real crew events + feed digest into the agent ctx
- CrewAgentPanel: crew-specific suggestions, crew-aware copy
- Replaced 🐝 bee emoji with the Sasquatch mascot avatar everywhere
(panel header/rows/empty state, crew page button, CrewForm toggle)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
A notification center should surface the user's own actionable items, not a
generic nav shortcut — and it showed on every tab (including Social) where it
made even less sense. Removed the footer entirely; the tabs now sit flush.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
useConversations reported unreadCount: 0 for everything, so the inbox never
showed new messages. Add a lightweight, reliable unread signal:
- useDMRead: per-conversation, per-user last-read timestamps in localStorage
(local-only; compares against the inner rumor's real created_at, not the
fuzzed gift-wrap time, so it's accurate).
- DirectMessagesPage: a conversation is unread when its latest message is from
the other person and newer than last-read — rendered as a red dot, bolder
name, brighter preview text, and a subtle highlighted card.
- ConversationPage: opening a chat marks it read up to the newest message, so
the dot clears on return (each page mounts fresh, so no stale state).
Scope: kept to the Messages page. Deliberately did NOT add a global nav badge,
which would require background-decrypting all gift wraps on every page — the
same heavy cost avoided for the crew bell.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Per request, a display name is now mandatory when signing up:
- Removed the "Skip for now" button from the profile step — the only forward
path is Save, which is disabled until a non-empty name is entered.
- Marked the field required (*), added helper copy ("Required — how you'll show
up across SkateSpots. Change it anytime."), and a hint under the button.
- finishSignup guards against an empty/whitespace name (defense-in-depth for
keyboard/Enter), trims the name before publishing kind 0.
- Reworded the step copy: name required, bio + avatar still optional.
Only affects the primary nsec signup dialog (also the endpoint the mnemonic
flow feeds into); the mnemonic dialog has no name step.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>