perf: faster shell, lazy wallet stack, serialized Amber and navigation polish
This commit is contained in:
parent
f8a1054894
commit
f95adeecf1
12 changed files with 427 additions and 95 deletions
|
|
@ -6,10 +6,11 @@ import { BECOME_ROLES } from "./lib/becomeRoles";
|
||||||
import { DeepLinkHandler } from "./components/DeepLinkHandler";
|
import { DeepLinkHandler } from "./components/DeepLinkHandler";
|
||||||
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
|
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
|
import { RoutePreloader } from "./components/RoutePreloader";
|
||||||
|
|
||||||
// MapPage is eager: it is the homepage and a lazy import here used to leave a
|
// HomePage resolves to a lazy map on web (smaller first entry) and to the
|
||||||
// blank Suspense fallback on slow native WebViews before the map appeared.
|
// eager MapPage via a Capacitor-only Vite alias on Android.
|
||||||
import MapPage from "./pages/MapPage";
|
import HomePage from "@/pages/HomePage";
|
||||||
|
|
||||||
// Lazy load everything else — each page becomes its own JS chunk
|
// Lazy load everything else — each page becomes its own JS chunk
|
||||||
const Index = lazyWithRetry(() => import("./pages/Index"));
|
const Index = lazyWithRetry(() => import("./pages/Index"));
|
||||||
|
|
@ -99,6 +100,7 @@ export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<DeepLinkHandler />
|
<DeepLinkHandler />
|
||||||
|
<RoutePreloader />
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
{/* Per-route boundary: a crash in one page shows a recoverable message
|
{/* Per-route boundary: a crash in one page shows a recoverable message
|
||||||
instead of blanking the whole app. Resets automatically on navigate
|
instead of blanking the whole app. Resets automatically on navigate
|
||||||
|
|
@ -111,9 +113,9 @@ export function AppRouter() {
|
||||||
navigation. Pages no longer wrap themselves in <Layout>. */}
|
navigation. Pages no longer wrap themselves in <Layout>. */}
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
{/* Map is the homepage */}
|
{/* Map is the homepage */}
|
||||||
<Route path="/" element={<MapPage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
{/* /map redirects to / */}
|
{/* /map redirects to / */}
|
||||||
<Route path="/map" element={<MapPage />} />
|
<Route path="/map" element={<HomePage />} />
|
||||||
{/* Legacy home page still accessible */}
|
{/* Legacy home page still accessible */}
|
||||||
<Route path="/home" element={<Index />} />
|
<Route path="/home" element={<Index />} />
|
||||||
<Route path="/events" element={<EventsPage />} />
|
<Route path="/events" element={<EventsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ZapDialog } from '@/components/ZapDialog';
|
import { LazyZapDialog } from '@/components/LazyZapDialog';
|
||||||
import { useZaps } from '@/hooks/useZaps';
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -64,7 +64,7 @@ export function FundingGoalCard({
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ZapDialog target={event}>
|
<LazyZapDialog target={event}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#ccff00] py-2 text-sm font-bold uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
|
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#ccff00] py-2 text-sm font-bold uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
|
||||||
|
|
@ -72,7 +72,7 @@ export function FundingGoalCard({
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
{reached ? 'Keep supporting' : 'Fund this event'}
|
{reached ? 'Keep supporting' : 'Fund this event'}
|
||||||
</button>
|
</button>
|
||||||
</ZapDialog>
|
</LazyZapDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,30 @@ function hasCashuWalletHint(): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-level Suspense fallback. Keeps the header and bottom nav mounted while a
|
||||||
|
* lazy route chunk loads, so tapping a menu item never blanks the whole app.
|
||||||
|
* The fade is delayed slightly: a fast chunk resolves before it becomes visible.
|
||||||
|
*/
|
||||||
|
function RouteLoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl px-4 py-6" aria-hidden="true">
|
||||||
|
<div style={{ animation: 'pageFallbackIn 180ms ease 120ms both' }}>
|
||||||
|
<div className="animate-pulse motion-reduce:animate-none space-y-5">
|
||||||
|
<div className="h-8 w-48 rounded-lg bg-white/[0.07]" />
|
||||||
|
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
|
||||||
|
<div className="grid gap-3 pt-3 sm:grid-cols-2">
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
|
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
|
||||||
* there are no children. */
|
* there are no children. */
|
||||||
|
|
@ -159,6 +183,7 @@ function NavLink({
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
|
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
|
|
@ -399,6 +424,7 @@ function MobileNav({
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
to={tab.href}
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
onTouchStart={startPress}
|
onTouchStart={startPress}
|
||||||
onTouchEnd={cancelPress}
|
onTouchEnd={cancelPress}
|
||||||
onTouchMove={cancelPress}
|
onTouchMove={cancelPress}
|
||||||
|
|
@ -526,6 +552,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const hideSasquatch = /^\/(become|create|edit|supporters|onboarding)(\/|$)/.test(location.pathname);
|
const hideSasquatch = /^\/(become|create|edit|supporters|onboarding)(\/|$)/.test(location.pathname);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [mobileLanguageView, setMobileLanguageView] = useState(false);
|
const [mobileLanguageView, setMobileLanguageView] = useState(false);
|
||||||
|
|
||||||
|
// Back/forward, notification taps and global search can change routes without
|
||||||
|
// clicking a menu row. Never leave the burger sheet covering the new page.
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
setMobileLanguageView(false);
|
||||||
|
}, [location.pathname]);
|
||||||
// One auth dialog for the header + burger menu. Owning it here (not inside
|
// One auth dialog for the header + burger menu. Owning it here (not inside
|
||||||
// LoginArea) keeps it above the sheet and outside the sheet's unmount tree.
|
// LoginArea) keeps it above the sheet and outside the sheet's unmount tree.
|
||||||
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
|
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
|
||||||
|
|
@ -1183,7 +1216,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
|
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
|
||||||
>
|
>
|
||||||
{children ?? <Outlet />}
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
|
{children ?? <Outlet />}
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer - Desktop */}
|
{/* Footer - Desktop */}
|
||||||
|
|
|
||||||
72
src/components/LazyZapDialog.tsx
Normal file
72
src/components/LazyZapDialog.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import {
|
||||||
|
cloneElement,
|
||||||
|
isValidElement,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useState,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import type { Event } from 'nostr-tools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight zap trigger.
|
||||||
|
*
|
||||||
|
* The full ZapDialog pulls in the Cashu wallet stack (`useCashu`, mint repos,
|
||||||
|
* token operations) — several hundred KB that used to land in the initial
|
||||||
|
* bundle because every SpotCard rendered a ZapButton. This wrapper keeps only
|
||||||
|
* the trigger in the entry chunk and loads the actual dialog on first tap.
|
||||||
|
*/
|
||||||
|
const LoadedZapDialog = lazy(() =>
|
||||||
|
import('@/components/ZapDialog').then((m) => ({ default: m.ZapDialog })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface LazyZapDialogProps {
|
||||||
|
target: Event;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LazyZapDialog({ target, children, className }: LazyZapDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (isValidElement(children)) {
|
||||||
|
const child = children as ReactElement<{ onClick?: (event: MouseEvent) => void }>;
|
||||||
|
child.props.onClick?.(event);
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = isValidElement(children) ? (
|
||||||
|
cloneElement(
|
||||||
|
children as ReactElement<{ onClick?: (event: MouseEvent) => void; className?: string }>,
|
||||||
|
{
|
||||||
|
onClick: handleClick,
|
||||||
|
className: [
|
||||||
|
(children as ReactElement<{ className?: string }>).props.className,
|
||||||
|
'cursor-pointer',
|
||||||
|
].filter(Boolean).join(' '),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className={className} onClick={handleClick}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger}
|
||||||
|
{open && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoadedZapDialog
|
||||||
|
target={target}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
hideTrigger
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/RoutePreloader.tsx
Normal file
82
src/components/RoutePreloader.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover / touch prefetch for the primary navigation destinations.
|
||||||
|
*
|
||||||
|
* The route chunks stay lazy (so first paint is not blocked), but once the user
|
||||||
|
* shows intent — hovers a menu row, tabs into a link, or touches it — the page
|
||||||
|
* module starts downloading. On mobile the touchstart head start is often enough
|
||||||
|
* to avoid the page skeleton entirely. Only modules are fetched; no page hooks
|
||||||
|
* mount, so this never triggers relay work or an Amber prompt.
|
||||||
|
*/
|
||||||
|
const loaders: Record<string, () => Promise<unknown>> = {
|
||||||
|
'/lab': () => import('@/pages/LabPage'),
|
||||||
|
'/events': () => import('@/pages/EventsPage'),
|
||||||
|
'/crews': () => import('@/pages/CrewsPage'),
|
||||||
|
'/community': () => import('@/pages/CommunityPage'),
|
||||||
|
'/skaters': () => import('@/pages/SkatersPage'),
|
||||||
|
'/messages': () => import('@/pages/DirectMessagesPage'),
|
||||||
|
'/settings': () => import('@/pages/SettingsPage'),
|
||||||
|
'/wallet': () => import('@/pages/WalletPage'),
|
||||||
|
'/stash': () => import('@/pages/StashPage'),
|
||||||
|
'/marketplace': () => import('@/pages/MarketplaceHubPage'),
|
||||||
|
'/stats': () => import('@/pages/CommunityStatsPage'),
|
||||||
|
'/collections': () => import('@/pages/SpotCollectionsPage'),
|
||||||
|
'/glossary': () => import('@/pages/GlossaryPage'),
|
||||||
|
'/about': () => import('@/pages/AboutPage'),
|
||||||
|
'/card': () => import('@/pages/RiderCardPage'),
|
||||||
|
'/supporters': () => import('@/pages/SupportersPage'),
|
||||||
|
'/newsletter': () => import('@/pages/NewsletterPage'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const preloaded = new Set<string>();
|
||||||
|
|
||||||
|
function routePath(anchor: HTMLAnchorElement): string | null {
|
||||||
|
const href = anchor.getAttribute('href') ?? '';
|
||||||
|
if (!href || href.startsWith('http') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// HashRouter renders to="#/lab" as href="#/lab".
|
||||||
|
if (href.startsWith('#/')) return href.slice(1).split('?')[0] || '/';
|
||||||
|
try {
|
||||||
|
const url = new URL(anchor.href, window.location.href);
|
||||||
|
if (url.hash.startsWith('#/')) return url.hash.slice(1).split('?')[0] || '/';
|
||||||
|
return url.pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preloadFromTarget(target: EventTarget | null): void {
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
const anchor = target.closest('a[href]');
|
||||||
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||||
|
const path = routePath(anchor);
|
||||||
|
if (!path || preloaded.has(path)) return;
|
||||||
|
const loader = loaders[path];
|
||||||
|
if (!loader) return;
|
||||||
|
preloaded.add(path);
|
||||||
|
void loader().catch(() => {
|
||||||
|
// A failed prefetch is harmless; lazyWithRetry will retry on navigation.
|
||||||
|
preloaded.delete(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutePreloader() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNativePlatform()) return;
|
||||||
|
|
||||||
|
const onIntent = (event: Event) => preloadFromTarget(event.target);
|
||||||
|
document.addEventListener('pointerover', onIntent, { capture: true, passive: true });
|
||||||
|
document.addEventListener('focusin', onIntent, { capture: true, passive: true });
|
||||||
|
document.addEventListener('touchstart', onIntent, { capture: true, passive: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerover', onIntent, true);
|
||||||
|
document.removeEventListener('focusin', onIntent, true);
|
||||||
|
document.removeEventListener('touchstart', onIntent, true);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ZapDialog } from '@/components/ZapDialog';
|
import { LazyZapDialog } from '@/components/LazyZapDialog';
|
||||||
import { useZaps } from '@/hooks/useZaps';
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -65,7 +65,7 @@ export function ZapButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ZapDialog target={target}>
|
<LazyZapDialog target={target}>
|
||||||
<div className={`flex items-center gap-1 ${className}`}>
|
<div className={`flex items-center gap-1 ${className}`}>
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
|
|
@ -78,6 +78,6 @@ export function ZapButton({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ZapDialog>
|
</LazyZapDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef, forwardRef } from 'react';
|
import { useState, useEffect, useRef, forwardRef, useCallback } from 'react';
|
||||||
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X, Wallet } from 'lucide-react';
|
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X, Wallet } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -42,6 +42,11 @@ interface ZapDialogProps {
|
||||||
target: Event;
|
target: Event;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Controlled open state (used by LazyZapDialog). */
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
/** Skip the built-in trigger when the caller owns the trigger. */
|
||||||
|
hideTrigger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const presetAmounts = [
|
const presetAmounts = [
|
||||||
|
|
@ -420,9 +425,21 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
||||||
});
|
});
|
||||||
ZapContent.displayName = 'ZapContent';
|
ZapContent.displayName = 'ZapContent';
|
||||||
|
|
||||||
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
export function ZapDialog({
|
||||||
|
target,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
hideTrigger = false,
|
||||||
|
}: ZapDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = controlledOpen ?? internalOpen;
|
||||||
|
const setOpen = useCallback((next: boolean) => {
|
||||||
|
if (controlledOpen !== undefined) onOpenChange?.(next);
|
||||||
|
else setInternalOpen(next);
|
||||||
|
}, [controlledOpen, onOpenChange]);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data: author } = useAuthor(target.pubkey);
|
const { data: author } = useAuthor(target.pubkey);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -664,11 +681,13 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||||
shouldScaleBackground={false}
|
shouldScaleBackground={false}
|
||||||
fadeFromIndex={0}
|
fadeFromIndex={0}
|
||||||
>
|
>
|
||||||
<DrawerTrigger asChild>
|
{!hideTrigger && children != null && (
|
||||||
<div className={`cursor-pointer ${className || ''}`}>
|
<DrawerTrigger asChild>
|
||||||
{children}
|
<div className={`cursor-pointer ${className || ''}`}>
|
||||||
</div>
|
{children}
|
||||||
</DrawerTrigger>
|
</div>
|
||||||
|
</DrawerTrigger>
|
||||||
|
)}
|
||||||
<DrawerContent
|
<DrawerContent
|
||||||
key={invoice ? 'payment' : 'form'}
|
key={invoice ? 'payment' : 'form'}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -732,11 +751,13 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
{!hideTrigger && children != null && (
|
||||||
<div className={`cursor-pointer ${className || ''}`}>
|
<DialogTrigger asChild>
|
||||||
{children}
|
<div className={`cursor-pointer ${className || ''}`}>
|
||||||
</div>
|
{children}
|
||||||
</DialogTrigger>
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
|
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg break-words">
|
<DialogTitle className="text-base sm:text-lg break-words">
|
||||||
|
|
|
||||||
|
|
@ -970,3 +970,10 @@ select {
|
||||||
:lang(zh) .tracking-wide {
|
:lang(zh) .tracking-wide {
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Delayed fade for the in-layout route skeleton. Route chunks that resolve
|
||||||
|
quickly never show a flash; slow ones fade in under the persistent chrome. */
|
||||||
|
@keyframes pageFallbackIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,20 @@ function getNativeSigner(): AmberIntentNative | null {
|
||||||
return nativePlugin;
|
return nativePlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amber is a single foreground Activity. Two concurrent intents can race: one
|
||||||
|
* is answered, the others sit until the 60s native timeout, and the user sees
|
||||||
|
* Amber apparently open twice / behave unpredictably. Every native call goes
|
||||||
|
* through this one-at-a-time queue. The same request is also coalesced below,
|
||||||
|
* so duplicate hook work never asks Amber twice for the same thing.
|
||||||
|
*/
|
||||||
|
let amberTail: Promise<void> = Promise.resolve();
|
||||||
|
function runAmber<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const run = amberTail.then(operation, operation);
|
||||||
|
amberTail = run.then(() => undefined, () => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
/** Turn Amber's "rejected" rejection into a friendly, catchable message. */
|
/** Turn Amber's "rejected" rejection into a friendly, catchable message. */
|
||||||
function normalizeAmberError(e: unknown): Error {
|
function normalizeAmberError(e: unknown): Error {
|
||||||
const msg = (e as Error)?.message ?? String(e);
|
const msg = (e as Error)?.message ?? String(e);
|
||||||
|
|
@ -94,45 +108,77 @@ async function decodeAmberResult(value: string): Promise<string> {
|
||||||
* pre-authorize all the permissions the app needs so Amber can (optionally)
|
* pre-authorize all the permissions the app needs so Amber can (optionally)
|
||||||
* remember the user's choice for a smoother, popup-light experience afterwards.
|
* remember the user's choice for a smoother, popup-light experience afterwards.
|
||||||
*/
|
*/
|
||||||
export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
let publicKeyInFlight: Promise<string> | null = null;
|
||||||
const plugin = getNativeSigner();
|
|
||||||
if (!plugin) {
|
export function getAmberPublicKeyViaPlugin(): Promise<string> {
|
||||||
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
if (publicKeyInFlight) return publicKeyInFlight;
|
||||||
|
|
||||||
|
const task = (async (): Promise<string> => {
|
||||||
|
const plugin = getNativeSigner();
|
||||||
|
if (!plugin) {
|
||||||
|
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = JSON.stringify([
|
||||||
|
{ type: 'get_public_key' },
|
||||||
|
{ type: 'sign_event' },
|
||||||
|
{ type: 'nip04_encrypt' },
|
||||||
|
{ type: 'nip04_decrypt' },
|
||||||
|
{ type: 'nip44_encrypt' },
|
||||||
|
{ type: 'nip44_decrypt' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result: { result?: string; package?: string };
|
||||||
|
try {
|
||||||
|
result = await runAmber(() => plugin.getPublicKey({ permissions }));
|
||||||
|
} catch (e) {
|
||||||
|
throw normalizeAmberError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSObject from Capacitor — normalize to a plain object for reliable access.
|
||||||
|
const plain = JSON.parse(JSON.stringify(result)) as Record<string, string>;
|
||||||
|
const value = await decodeAmberResult(plain.result ?? plain.npub ?? plain.pubkey ?? '');
|
||||||
|
|
||||||
|
if (!value) throw new Error('Amber returned empty key');
|
||||||
|
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(value)) {
|
||||||
|
return value.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('npub1')) {
|
||||||
|
const decoded = nip19.decode(value);
|
||||||
|
if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber');
|
||||||
|
return decoded.data as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected Amber response: "${value.slice(0, 30)}"`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
publicKeyInFlight = task;
|
||||||
|
void task.finally(() => {
|
||||||
|
if (publicKeyInFlight === task) publicKeyInFlight = null;
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signInFlight = new Map<string, Promise<{ result?: string; event?: string; id?: string }>>();
|
||||||
|
const cryptoInFlight = new Map<string, Promise<string>>();
|
||||||
|
/**
|
||||||
|
* Decrypt-only result cache. Decryption is deterministic for a given
|
||||||
|
* (identity, sender, ciphertext), so a re-render or a second screen can reuse
|
||||||
|
* the plaintext without making Amber ask again. Encrypt results are NOT cached
|
||||||
|
* (NIP-44 encryption is randomized). Memory-only and bounded; encrypted/private
|
||||||
|
* content is also cached by the feature hooks themselves.
|
||||||
|
*/
|
||||||
|
const DECRYPT_CACHE_MAX = 600;
|
||||||
|
const decryptCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function rememberDecrypt(key: string, value: string): void {
|
||||||
|
if (decryptCache.size >= DECRYPT_CACHE_MAX) {
|
||||||
|
const oldest = decryptCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) decryptCache.delete(oldest);
|
||||||
}
|
}
|
||||||
|
decryptCache.set(key, value);
|
||||||
const permissions = JSON.stringify([
|
|
||||||
{ type: 'get_public_key' },
|
|
||||||
{ type: 'sign_event' },
|
|
||||||
{ type: 'nip04_encrypt' },
|
|
||||||
{ type: 'nip04_decrypt' },
|
|
||||||
{ type: 'nip44_encrypt' },
|
|
||||||
{ type: 'nip44_decrypt' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
let result: { result?: string; package?: string };
|
|
||||||
try {
|
|
||||||
result = await plugin.getPublicKey({ permissions });
|
|
||||||
} catch (e) {
|
|
||||||
throw normalizeAmberError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSObject from Capacitor — normalize to a plain object for reliable access.
|
|
||||||
const plain = JSON.parse(JSON.stringify(result)) as Record<string, string>;
|
|
||||||
const value = await decodeAmberResult(plain.result ?? plain.npub ?? plain.pubkey ?? '');
|
|
||||||
|
|
||||||
if (!value) throw new Error('Amber returned empty key');
|
|
||||||
|
|
||||||
if (/^[0-9a-f]{64}$/i.test(value)) {
|
|
||||||
return value.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.startsWith('npub1')) {
|
|
||||||
const decoded = nip19.decode(value);
|
|
||||||
if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber');
|
|
||||||
return decoded.data as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unexpected Amber response: "${value.slice(0, 30)}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CapacitorAmberSigner implements NostrSigner {
|
export class CapacitorAmberSigner implements NostrSigner {
|
||||||
|
|
@ -159,12 +205,25 @@ export class CapacitorAmberSigner implements NostrSigner {
|
||||||
|
|
||||||
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
||||||
const plugin = this.plugin();
|
const plugin = this.plugin();
|
||||||
let res: { result?: string; event?: string };
|
const eventJson = JSON.stringify(event);
|
||||||
|
const requestKey = `${this.npub}:${eventJson}`;
|
||||||
|
let res: { result?: string; event?: string; id?: string };
|
||||||
try {
|
try {
|
||||||
res = await plugin.signEvent({
|
const existing = signInFlight.get(requestKey);
|
||||||
eventJson: JSON.stringify(event),
|
if (existing) {
|
||||||
npub: this.npub,
|
res = await existing;
|
||||||
});
|
} else {
|
||||||
|
const task = runAmber(() => plugin.signEvent({
|
||||||
|
eventJson,
|
||||||
|
npub: this.npub,
|
||||||
|
}));
|
||||||
|
signInFlight.set(requestKey, task);
|
||||||
|
try {
|
||||||
|
res = await task;
|
||||||
|
} finally {
|
||||||
|
signInFlight.delete(requestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw normalizeAmberError(e);
|
throw normalizeAmberError(e);
|
||||||
}
|
}
|
||||||
|
|
@ -189,34 +248,61 @@ export class CapacitorAmberSigner implements NostrSigner {
|
||||||
return signed;
|
return signed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue + coalesce one crypto operation. A second identical decrypt/encrypt
|
||||||
|
* while the first is open waits on the same native result instead of opening
|
||||||
|
* a second Amber screen.
|
||||||
|
*/
|
||||||
|
private async crypto(
|
||||||
|
type: 'nip04_encrypt' | 'nip04_decrypt' | 'nip44_encrypt' | 'nip44_decrypt',
|
||||||
|
pubkey: string,
|
||||||
|
payload: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const key = `${type}:${this.npub}:${pubkey}:${payload}`;
|
||||||
|
if (type.endsWith('_decrypt')) {
|
||||||
|
const cached = decryptCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
}
|
||||||
|
const existing = cryptoInFlight.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const task = (async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const plugin = this.plugin();
|
||||||
|
const { result } = type === 'nip04_encrypt'
|
||||||
|
? await runAmber(() => plugin.nip04Encrypt({ payload, pubKey: pubkey, npub: this.npub }))
|
||||||
|
: type === 'nip04_decrypt'
|
||||||
|
? await runAmber(() => plugin.nip04Decrypt({ payload, pubKey: pubkey, npub: this.npub }))
|
||||||
|
: type === 'nip44_encrypt'
|
||||||
|
? await runAmber(() => plugin.nip44Encrypt({ payload, pubKey: pubkey, npub: this.npub }))
|
||||||
|
: await runAmber(() => plugin.nip44Decrypt({ payload, pubKey: pubkey, npub: this.npub }));
|
||||||
|
const decoded = await decodeAmberResult(result);
|
||||||
|
if (type.endsWith('_decrypt')) rememberDecrypt(key, decoded);
|
||||||
|
return decoded;
|
||||||
|
} catch (e) {
|
||||||
|
throw normalizeAmberError(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
cryptoInFlight.set(key, task);
|
||||||
|
try {
|
||||||
|
return await task;
|
||||||
|
} finally {
|
||||||
|
cryptoInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
readonly nip04 = {
|
readonly nip04 = {
|
||||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> =>
|
||||||
try {
|
this.crypto('nip04_encrypt', pubkey, plaintext),
|
||||||
const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> =>
|
||||||
return decodeAmberResult(result);
|
this.crypto('nip04_decrypt', pubkey, ciphertext),
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
|
|
||||||
return decodeAmberResult(result);
|
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly nip44 = {
|
readonly nip44 = {
|
||||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> =>
|
||||||
try {
|
this.crypto('nip44_encrypt', pubkey, plaintext),
|
||||||
const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> =>
|
||||||
return decodeAmberResult(result);
|
this.crypto('nip44_decrypt', pubkey, ciphertext),
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
|
|
||||||
return decodeAmberResult(result);
|
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
7
src/pages/HomePage.native.tsx
Normal file
7
src/pages/HomePage.native.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Capacitor build alias for the home route. The native WebView boots directly
|
||||||
|
* into the map; do not turn this into a lazy chunk here — the Android shell
|
||||||
|
* serves a single inlined bundle and an unresolved lazy import was the cause of
|
||||||
|
* a black boot screen.
|
||||||
|
*/
|
||||||
|
export { default } from './MapPage';
|
||||||
11
src/pages/HomePage.tsx
Normal file
11
src/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { lazyWithRetry } from '@/lib/lazyWithRetry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web/desktop home route: keep the map (and its card/Zap dependencies) out of
|
||||||
|
* the initial entry chunk. Vite aliases this module to HomePage.native.tsx in
|
||||||
|
* the Capacitor build, where the map stays eager so the native WebView can
|
||||||
|
* never show a blank Suspense screen on boot.
|
||||||
|
*/
|
||||||
|
const HomePage = lazyWithRetry(() => import('./MapPage'));
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
|
|
@ -66,9 +66,18 @@ export default defineConfig(() => ({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
// Capacitor keeps the map eager (build-time alias), while the web build
|
||||||
"@": path.resolve(__dirname, "./src"),
|
// lazy-loads it to keep the initial entry chunk smaller. Both modules
|
||||||
},
|
// resolve to the same MapPage; this only changes STATIC vs DYNAMIC import.
|
||||||
|
alias: [
|
||||||
|
...(process.env.CAPACITOR_BUILD
|
||||||
|
? [{
|
||||||
|
find: '@/pages/HomePage',
|
||||||
|
replacement: path.resolve(__dirname, './src/pages/HomePage.native.tsx'),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['@lightninglabs/lnc-web', '@xenova/transformers'],
|
exclude: ['@lightninglabs/lnc-web', '@xenova/transformers'],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue