From 5dffa5cac3379389344dfaac9b8b4f18436f1e21 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 23 Jun 2026 09:33:46 +0700 Subject: [PATCH] feat: UX improvements - credits, draggable sasquatch, mobile fixes 1. AboutPage: add Credits section (Zapstore, ContextVM, Angor, Geyser, MKStack, Shakespeare, Nostr), add Zapstore download button, fix powered-by text overflow on mobile (wrap-friendly flex layout) 2. SasquatchGuide: make minimized bubble draggable on all devices - pointer events for mouse, touch, and stylus - saves position to localStorage, persists across sessions - tap still opens, drag moves (5px threshold to distinguish) 3. Layout (burger menu): add overflow-y-auto + pb-8 to SheetContent so mobile menu is scrollable when content exceeds screen height 4. CashuWalletCard (wallet): fix input+button rows overflowing on mobile - flex-col on small screens, flex-row on xs+ (stacked on phones) - whitespace-nowrap on buttons to prevent text wrapping 5. LabPage: compact action buttons on mobile (h-8, text-[10px], px-2) so all 4 buttons fit in 2x2 grid without overflow/truncation 6. LoginDialog (Amber): improve visibility-change handler - use fresh SimplePool subscription on each foreground return instead of querySync (which doesn't exist in nostr-tools v2.13) Co-authored-by: shakespeare.diy --- src/components/Layout.tsx | 4 +- src/components/SasquatchGuide.tsx | 78 +++++++++++++++++--- src/components/auth/LoginDialog.tsx | 67 +++++++++-------- src/components/wallet/CashuWalletCard.tsx | 74 +++++++++---------- src/pages/AboutPage.tsx | 88 +++++++++++++++++++---- src/pages/LabPage.tsx | 28 ++++---- 6 files changed, 231 insertions(+), 108 deletions(-) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9483a52..17c6c27 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -571,8 +571,8 @@ export function Layout({ children }: LayoutProps) { - -
+ +
{/* Wallet Card - Mobile Menu */} {user && ( (null); + // Draggable position state for minimized bubble + const [dragPos, setDragPos] = useLocalStorage<{ x: number; y: number } | null>('sasquatch-pos', null); + const [isDragging, setIsDragging] = useState(false); + const dragRef = useRef(null); + const dragStart = useRef<{ mx: number; my: number; bx: number; by: number } | null>(null); + const dragMoved = useRef(false); + + // Dragging handlers + const onPointerDown = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + dragMoved.current = false; + const rect = dragRef.current.getBoundingClientRect(); + dragStart.current = { mx: e.clientX, my: e.clientY, bx: rect.left, by: rect.top }; + dragRef.current.setPointerCapture(e.pointerId); + setIsDragging(false); + }, []); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragStart.current) return; + const dx = e.clientX - dragStart.current.mx; + const dy = e.clientY - dragStart.current.my; + if (!dragMoved.current && Math.abs(dx) < 5 && Math.abs(dy) < 5) return; + dragMoved.current = true; + setIsDragging(true); + const newX = Math.max(0, Math.min(window.innerWidth - 56, dragStart.current.bx + dx)); + const newY = Math.max(0, Math.min(window.innerHeight - 56, dragStart.current.by + dy)); + setDragPos({ x: newX, y: newY }); + }, [setDragPos]); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + if (dragRef.current) dragRef.current.releasePointerCapture(e.pointerId); + const wasDrag = dragMoved.current; + dragStart.current = null; + dragMoved.current = false; + setTimeout(() => setIsDragging(false), 50); + if (!wasDrag) { + setIsMinimized(false); + } + }, [setIsMinimized]); + + const bubbleStyle = useMemo(() => { + if (dragPos) { + return { position: 'fixed' as const, left: dragPos.x, top: dragPos.y, bottom: 'auto', right: 'auto', zIndex: 50 }; + } + return undefined; + }, [dragPos]); + useEffect(() => { if (message) { const timer = setTimeout(() => setIsVisible(true), 500); @@ -501,12 +548,25 @@ Instructions: return ( <> - {/* ── Minimized bubble — always visible, bottom-left ── */} + {/* ── Minimized bubble — draggable ── */} {isMinimized && ( -
- +
)} diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 9e6c051..ac6e206 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -157,49 +157,54 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const pending = pendingRef.current || loadPendingNC(); if (!pending || amberStep !== 'waiting') return; - setAmberStatus('Checking relay for Amber response...'); - - // Quick one-shot relay check when app comes back to foreground - const { nsecDecode } = await import('nostr-tools/nip19').catch(() => ({ nsecDecode: null })); - if (!nsecDecode) return; + setAmberStatus('Checking for Amber response...'); try { const decoded = nip19.decode(pending.clientNsec); if (decoded.type !== 'nsec') return; const clientSk = decoded.data; - const pool = new SimplePool(); const since = Math.floor(pending.timestamp / 1000) - 30; - // Fetch recent events — one-shot query - const events = await pool.querySync( - NOSTRCONNECT_RELAYS, - { - kinds: [24133], - '#p': [pending.clientPubkey], - since, - }, - { maxWait: 5000 } - ); + // Re-subscribe with a fresh pool each time app comes to foreground + const freshPool = new SimplePool(); + let found = false; - pool.close(NOSTRCONNECT_RELAYS); + await new Promise((resolve) => { + const timeout = setTimeout(() => { resolve(); }, 6000); - for (const event of events) { - try { - const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content); - const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string }; - if (parsed.error && parsed.error !== '') continue; - if (parsed.result !== pending.secret && parsed.result !== 'ack') continue; + const sub = freshPool.subscribeMany( + NOSTRCONNECT_RELAYS, + [{ kinds: [24133], '#p': [pending.clientPubkey], since }], + { + onevent: async (event) => { + if (found) return; + try { + const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content); + const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string }; + if (parsed.error && parsed.error !== '') return; + if (parsed.result !== pending.secret && parsed.result !== 'ack') return; + found = true; + clearTimeout(timeout); + sub.close(); + freshPool.close(NOSTRCONNECT_RELAYS); + pendingRef.current = null; + abortRef.current?.abort(); + await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays); + resolve(); + } catch { /* wrong encryption */ } + }, + oneose: () => { + // All caught up — wait a bit more then give up + setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 2000); + }, + } + ); + }); - // Found it! - pendingRef.current = null; - abortRef.current?.abort(); - await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays); - return; - } catch { /* wrong encryption or format */ } + if (!found) { + setAmberStatus('Not received yet. Did you approve in Amber? Tap retry or come back after approving.'); } - - setAmberStatus('No response yet — did you approve in Amber?'); } catch { /* ignore */ } }; diff --git a/src/components/wallet/CashuWalletCard.tsx b/src/components/wallet/CashuWalletCard.tsx index f9c8494..452ec09 100644 --- a/src/components/wallet/CashuWalletCard.tsx +++ b/src/components/wallet/CashuWalletCard.tsx @@ -294,24 +294,25 @@ export function CashuWalletCard() { {/* Receive Tab */} {/* Lightning Invoice */} -
- -
- setQuoteAmount(e.target.value)} - /> - -
-
+
+ +
+ setQuoteAmount(e.target.value)} + className="min-w-0" + /> + +
+
@@ -357,24 +358,25 @@ export function CashuWalletCard() { {/* Send Tab */} {/* Send Token */} -
- -
- setSendAmount(e.target.value)} - /> - -
-
+
+ +
+ setSendAmount(e.target.value)} + className="min-w-0" + /> + +
+
diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index d245bb5..6d80676 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -16,6 +16,8 @@ import { ExternalLink, BarChart3, AlertCircle, + Download, + Smartphone, } from 'lucide-react'; export default function AboutPage() { @@ -235,26 +237,82 @@ Version: v0.1.0`; + {/* Download on Zapstore */} + + +
+
+
+ +

Download the App

+
+

+ Install SkateSpots on Android via Zapstore — the open-source, Bitcoin-native app store. +

+
+ + + +
+
+
+ + {/* Credits */} + + +

+ + Credits +

+
+ {[ + { label: 'Built with', name: 'MKStack', url: 'https://soapbox.pub/mkstack', desc: 'Nostr client framework' }, + { label: 'Vibed with', name: 'Shakespeare', url: 'https://shakespeare.diy', desc: 'AI-powered development' }, + { label: 'Powered by', name: 'Nostr', url: 'https://nostr.com', desc: 'Decentralized protocol' }, + { label: 'Crowdfunding', name: 'Geyser', url: 'https://geyser.fund/project/skatespots', desc: 'Bitcoin Lightning crowdfunding' }, + { label: 'Crowdfunding', name: 'Angor', url: 'https://angor.io/', desc: 'Bitcoin crowdfunding platform' }, + { label: 'Distributed on', name: 'Zapstore', url: 'https://zapstore.dev/apps/org.skatespots.app', desc: 'Open-source app store' }, + { label: 'AI powered by', name: 'ContextVM', url: 'https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530', desc: 'Nostr AI infrastructure' }, + ].map((item) => ( + + +
+ {item.label}: + {item.name} +

{item.desc}

+
+
+ ))} +
+
+
+ {/* Powered By */} -
-

Powered by

-
- +
+

Powered by

+
+ Nostr • - - Decentralized - + Decentralized • - - Community-Owned - + Community-Owned
diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index fd6c231..a1a1726 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -111,29 +111,29 @@ export default function LabPage() {

{t('labPage.subtitle')}

{user ? ( -
- - - - - - - -