From 4ed1d9a8039b6bb19f4f24b43fd0a02f3d7a44c7 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 27 Aug 2026 13:27:24 +0700 Subject: [PATCH] Fix crew card layout, Amber web login hang, Stash encryption gate, and add signup welcome step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CrewCard: rebuilt to fit any screen width — clip overflow, truncate name/location, single wrapping badge row, consistent avatar sizing; responsive header - Amber web login: after opening Amber, drop user on manual paste step after 2.5s so the spinner never hangs when the callback opens in a new tab or clipboard is blocked; keep polling on the paste step so a late callback still auto-completes - Stash: hasNip44/hasNip04 now fall back to the active login when pubkey matching fails, so logged-in nsec/amber users are no longer wrongly told "Encryption Required" - Signup: added an explicit welcome step that explains account creation and only generates the keypair when the user taps "Create my account" (no more silent auto-creation on dialog open) Co-authored-by: shakespeare.diy --- src/components/auth/LoginDialog.tsx | 23 +++++- src/components/auth/SignupDialog.tsx | 71 ++++++++++++++++-- src/lib/signerCapabilities.ts | 26 ++++++- src/pages/CrewsPage.tsx | 107 ++++++++++++++------------- 4 files changed, 163 insertions(+), 64 deletions(-) diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index faba2d0..6efccad 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -80,12 +80,25 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog // Store return path before navigating away const currentPath = window.location.hash.replace(/^#/, '') || '/'; localStorage.setItem('amber_return_path', currentPath); - // Listen for app regaining focus (after Amber redirect) + // Listen for app regaining focus (after Amber redirect). If Amber's + // redirect succeeded, checkAmberPendingPubkey completes the login; if not, + // it drops the user on the paste step. const unsub = subscribeToAmberReturn(() => { checkAmberPendingPubkey(); }); window.__amberUnsubRef = unsub; openAmberForLogin(); + + // CRITICAL web-flow fallback: on mobile browsers Amber frequently opens the + // callback in a NEW tab (so the original tab never fires visibilitychange) + // and/or the clipboard read is blocked by permissions. In those cases the + // spinner would hang until the 3-minute timeout — exactly the "stuck + // loading" bug. So after a short grace period we surface the manual paste + // step, which ALWAYS works (user taps "Copy public key" in Amber, pastes + // here). The background poll still auto-completes if the callback lands. + window.setTimeout(() => { + setAmberStep((step) => (step === 'loading' ? 'paste' : step)); + }, 2500); }; // Check if AmberCallbackPage already wrote a pubkey to localStorage @@ -122,9 +135,12 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog setAmberStep('paste'); }; - // Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate tab) + // Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate + // tab). We keep polling on BOTH 'loading' and 'paste' so that even after we + // drop the user on the manual paste step, a late-arriving callback still + // auto-completes the login for them. useEffect(() => { - if (amberStep !== 'loading') return; + if (amberStep !== 'loading' && amberStep !== 'paste') return; const interval = setInterval(() => { const storedPubkey = localStorage.getItem('amber_pending_pubkey'); if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) { @@ -136,7 +152,6 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog // Stop polling after 3 minutes const timeout = setTimeout(() => { clearInterval(interval); - if (amberStep === 'loading') setAmberStep('paste'); }, 180_000); return () => { clearInterval(interval); clearTimeout(timeout); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 42bdc32..faed1af 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -27,7 +27,7 @@ const sanitizeFilename = (filename: string) => { } const SignupDialog: React.FC = ({ isOpen, onClose, onComplete }) => { - const [step, setStep] = useState<'profile' | 'done'>('profile'); + const [step, setStep] = useState<'welcome' | 'profile' | 'done'>('welcome'); const [nsec, setNsec] = useState(''); const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none'); const [showNsec, setShowNsec] = useState(false); @@ -199,6 +199,11 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete }; const getTitle = () => { + if (step === 'welcome') return ( + + Join SkateSpots + + ); if (step === 'profile') return ( Set Up Your Profile @@ -211,20 +216,28 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete ); }; - // Reset state when dialog opens, and silently create + log in the account - // so the skater lands straight on profile setup — no crypto steps, no gates. + // Reset state when dialog opens. We now start on an explicit WELCOME step and + // only create the keypair when the skater taps "Create my account" — this + // gives signup a real, understandable flow instead of silently minting an + // account behind their back the instant the dialog opens. useEffect(() => { if (isOpen) { - setStep('profile'); + setStep('welcome'); setNsec(''); setKeySecured('none'); setShowNsec(false); setProfileData({ name: '', about: '', picture: '' }); - createAccountSilently(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); + // Create the account (generate key + log in), then advance to profile setup. + const handleCreateAccount = () => { + if (createAccountSilently()) { + setStep('profile'); + } + }; + return ( = ({ isOpen, onClose, onComplete
+ {/* Welcome Step — explain what's about to happen, then create on tap */} + {step === 'welcome' && ( +
+
+
+
+
+ 🛹 +
+

+ Create your free account +

+

+ No email, no phone, no password. We'll generate a private key that lives + only on your device — that key is your account. +

+
+
+ +
+ {[ + ['🔑', 'A secure key is created just for you'], + ['📱', 'It stays on this device — nobody else can see it'], + ['💾', "You'll get a chance to back it up right after"], + ].map(([icon, label]) => ( +
+ {icon} + {label} +
+ ))} +
+ + +

+ By continuing you're generating a Nostr keypair. Advanced users can + {' '} + use a signer app + {' '}instead. +

+
+ )} + {/* Profile Step - Optional profile setup */} {step === 'profile' && (
diff --git a/src/lib/signerCapabilities.ts b/src/lib/signerCapabilities.ts index be10240..8deaf49 100644 --- a/src/lib/signerCapabilities.ts +++ b/src/lib/signerCapabilities.ts @@ -26,13 +26,32 @@ function windowNostr(): { nip44?: unknown; nip04?: unknown } | undefined { return (globalThis as { nostr?: { nip44?: unknown; nip04?: unknown } }).nostr; } +/** + * Resolve the login that backs the current user. + * + * IMPORTANT: we cannot rely solely on `l.pubkey === user.pubkey`. Depending on + * the login method and how Nostrify rehydrates from localStorage, the top-level + * `pubkey` on a stored login object is not always populated (nsec logins in + * particular expose the pubkey nested under the decrypted key material). When a + * pubkey match fails we fall back to the ACTIVE login (`logins[0]`), which is + * exactly the login `useCurrentUser` turns into `users[0]` / `user`. Without + * this fallback, a perfectly capable nsec/amber user gets wrongly told + * "Encryption Required" on the Stash page. + */ +function resolveLogin( + user: MaybeUser | null | undefined, + logins: readonly MaybeLogin[], +): MaybeLogin | undefined { + if (!user?.pubkey || logins.length === 0) return undefined; + return logins.find((l) => l.pubkey === user.pubkey) ?? logins[0]; +} + /** True if the user's signer can do NIP-44 encryption. Never throws. */ export function hasNip44( user: MaybeUser | null | undefined, logins: readonly MaybeLogin[], ): boolean { - if (!user?.pubkey) return false; - const login = logins.find((l) => l.pubkey === user.pubkey); + const login = resolveLogin(user, logins); if (!login) return false; // nsec + bunker: NIP-44 always available (pure JS / remote signer implements it) if (login.type === 'nsec' || login.type === 'bunker') return true; @@ -50,8 +69,7 @@ export function hasNip04( user: MaybeUser | null | undefined, logins: readonly MaybeLogin[], ): boolean { - if (!user?.pubkey) return false; - const login = logins.find((l) => l.pubkey === user.pubkey); + const login = resolveLogin(user, logins); if (!login) return false; if (login.type === 'nsec' || login.type === 'bunker') return true; if (login.type === 'x-amber') return true; diff --git a/src/pages/CrewsPage.tsx b/src/pages/CrewsPage.tsx index fc1e2e2..8f89bea 100644 --- a/src/pages/CrewsPage.tsx +++ b/src/pages/CrewsPage.tsx @@ -47,46 +47,68 @@ function CrewCard({ distanceKm: number | null; }) { const accent = crewAccent(crew); + const FocusIcon = crew.focus !== 'mixed' ? FOCUS_BADGE_ICONS[crew.focus] : null; return ( - + - + {crew.name.slice(0, 2).toUpperCase()} -
-

- {crew.name} + +
+ {/* Name + member count */} +
+

+ {crew.name} +

- {crew.members.length} + + {crew.members.length} -

+
-
+ {/* Location line */} + {(crew.location || distanceKm !== null) && ( +

+ + {crew.location} + {distanceKm !== null && ( + + · {formatDistance(distanceKm)} + + )} +

+ )} + + {/* Badges — all on one wrapping row, clipped so nothing spills */} +
- {crew.focus !== 'mixed' && (() => { - const FocusIcon = FOCUS_BADGE_ICONS[crew.focus]; - return ( - - - {CREW_FOCUS_LABELS[crew.focus]} - - ); - })()} + {FocusIcon && ( + + + {CREW_FOCUS_LABELS[crew.focus]} + + )} {crew.bandName && ( )} + {crew.vibes.slice(0, 2).map((v) => ( + + {CREW_VIBE_LABELS[v] || v} + + ))}
- - {(crew.location || distanceKm !== null) && ( -

- - {crew.location} - {distanceKm !== null && ( - - · {formatDistance(distanceKm)} - - )} -

- )} - - {crew.vibes.length > 0 && ( -
- {crew.vibes.slice(0, 3).map((v) => ( - - {CREW_VIBE_LABELS[v] || v} - - ))} -
- )}
); @@ -193,13 +198,13 @@ export default function CrewsPage() {
{/* Header */} -
-
+
+

- Crews + Crews

Your map-anchored skate family — the people you skate with, not who you hire (that's The Lab).

{/* Social-proof stat line */} @@ -210,7 +215,7 @@ export default function CrewsPage() {

)}
- +