Fix Amber NIP-44 gate on Stash, mobile Crew detail layout, remove Quick Snap

- signerCapabilities: recognize x-amber login type as NIP-44/NIP-04
  capable (Amber implements nip44_encrypt/decrypt via native intents and
  URL redirects). Fixes "Encryption Required" wall on Stash for users
  logged in with Amber on Android.
- CrewDetailPage: restructure hero header so actions wrap below the
  title on mobile instead of squeezing it in a cramped 3-column flex;
  smaller avatar/title on small screens, truncating location.
- MapPage: remove Quick Snap from the mobile Add speed-dial and all its
  handlers, refs, web-fallback input, and now-unused imports.
- zapstore.yaml: explicit orange icon + metadata_sources: [] so the
  correct logo is always used on the Zapstore listing.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 12:31:34 +07:00
parent f836763e75
commit a83611072a
4 changed files with 99 additions and 186 deletions

View file

@ -36,9 +36,12 @@ export function hasNip44(
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;
// amber (NIP-55): the Amber signer implements nip44_encrypt / nip44_decrypt
// over native intents (CapacitorAmberSigner) and URL redirects (AmberSigner).
if (login.type === 'x-amber') return true;
// extension: probe window.nostr directly (no throwing getter)
if (login.type === 'extension') return !!windowNostr()?.nip44;
// amber / unknown: no NIP-44
// unknown: no NIP-44
return false;
}
@ -51,6 +54,7 @@ export function hasNip04(
const login = logins.find((l) => l.pubkey === user.pubkey);
if (!login) return false;
if (login.type === 'nsec' || login.type === 'bunker') return true;
if (login.type === 'x-amber') return true;
if (login.type === 'extension') return !!windowNostr()?.nip04;
return false;
}

View file

@ -239,80 +239,89 @@ export default function CrewDetailPage() {
{/* Hero */}
<Card className="bg-black/90 border-2 border-teal-500/40 overflow-hidden">
<CardContent className="pt-6">
<div className="flex items-start gap-4 mb-4">
<Avatar className="h-20 w-20 rounded-2xl border-2 border-teal-500/40">
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black text-xl">
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{crew.name}
</h1>
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
<Users className="h-3 w-3" /> Crew
</Badge>
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
<CrewRepBadge crew={crew} />
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
{crew.location && (
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4 text-teal-400" /> {crew.location}
</span>
)}
{crew.founded && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> Since {crew.founded}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
{canManage && (
<CrewInviteDialog
crewName={crew.name}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
isFounder={isFounder}
/>
)}
{isFounder && (
<div className="flex items-center gap-2">
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
<DeleteEntityDialog
event={crew.event}
entityLabel="crew"
entityName={crew.name}
redirectTo="/crews"
triggerText="Disband"
/>
<div className="mb-4">
{/* Top: avatar + title/meta. Actions move below on mobile so the
title never gets squeezed by the button column. */}
<div className="flex items-start gap-3 sm:gap-4">
<Avatar className="h-14 w-14 sm:h-20 sm:w-20 rounded-2xl border-2 border-teal-500/40 flex-shrink-0">
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black text-lg sm:text-xl">
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-black uppercase text-teal-400 break-words" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{crew.name}
</h1>
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
<Users className="h-3 w-3" /> Crew
</Badge>
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
<CrewRepBadge crew={crew} />
</div>
)}
{isAdmin && !isFounder && (
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
<ShieldCheck className="h-3 w-3" /> You're an admin
</Badge>
)}
{role === 'member' && (
<Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
<Users className="h-3 w-3" /> Member
</Badge>
)}
{role === 'none' && (
<CrewRequestToJoin
crewName={crew.name}
founderPubkey={crew.pubkey}
admins={crew.admins}
/>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400">
{crew.location && (
<span className="flex items-center gap-1 min-w-0">
<MapPin className="h-4 w-4 text-teal-400 flex-shrink-0" />
<span className="truncate">{crew.location}</span>
</span>
)}
{crew.founded && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4 flex-shrink-0" /> Since {crew.founded}
</span>
)}
</div>
</div>
</div>
{/* Actions: full-width wrap under the header, comfortable tap targets */}
{(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{canManage && (
<CrewInviteDialog
crewName={crew.name}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
isFounder={isFounder}
/>
)}
{isFounder && (
<>
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
<DeleteEntityDialog
event={crew.event}
entityLabel="crew"
entityName={crew.name}
redirectTo="/crews"
triggerText="Disband"
/>
</>
)}
{isAdmin && !isFounder && (
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
<ShieldCheck className="h-3 w-3" /> You're an admin
</Badge>
)}
{role === 'member' && (
<Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
<Users className="h-3 w-3" /> Member
</Badge>
)}
{role === 'none' && (
<CrewRequestToJoin
crewName={crew.name}
founderPubkey={crew.pubkey}
admins={crew.admins}
/>
)}
</div>
)}
</div>
{crew.bio && (

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, useRef, lazy, Suspense } from 'react';
import { useState, useMemo, useEffect, lazy, Suspense } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Layout } from '@/components/Layout';
@ -66,7 +66,6 @@ import {
Bitcoin,
SlidersHorizontal,
Check,
Camera,
Navigation,
ExternalLink,
MapPin,
@ -83,7 +82,6 @@ import {
import { SpotChat } from '@/components/SpotChat';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { isNativeCapture, captureFromNativeCamera } from '@/lib/quickCapture';
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
@ -603,16 +601,11 @@ export default function MapPage() {
return () => window.removeEventListener('sasquatch:panelchange', handler);
}, []);
// Mobile "Add" speed-dial (fans out Add Spot / Shop / Tour / Quick Snap)
// Mobile "Add" speed-dial (fans out Add Spot / Shop / Event / Gear / Tour)
const [addDialOpen, setAddDialOpen] = useState(false);
// Mobile layers popover
const [layersOpen, setLayersOpen] = useState(false);
// Quick Snap — camera-first. On native (the APK) we open the OS camera
// directly via the runtime Camera plugin (with its built-in preview/retake).
// On web there's no reliable way to force the camera from JS, so we fall back
// to a file input hinting the rear camera. A single photo is all a spot needs.
const quickSnapInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
// BTC Map center for fetching places
@ -797,80 +790,6 @@ export default function MapPage() {
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
};
// Quick Snap entry point — camera-first on native, file input on web.
const handleQuickSnap = async () => {
setAddDialOpen(false);
if (isNativeCapture()) {
try {
const file = await captureFromNativeCamera();
if (file) handleQuickCameraCapture(file);
return; // handled natively (user cancel returns null → do nothing)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn('[QuickSnap] native camera failed:', msg);
// A permission error means the OS blocked the camera. Don't silently
// open the gallery — that's confusing and looks like the bug. Tell the
// user how to fix it and stop here.
if (/permission|denied|not authorized|unavailable/i.test(msg)) {
toast({
title: 'Camera permission needed',
description:
'Allow camera access for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then try again.',
variant: 'destructive',
});
return;
}
// Any other native error → fall back to the photo picker so the user
// can still add a spot.
toast({
title: 'Camera unavailable',
description: 'Opening the photo picker instead.',
});
// fall through to the web/file-input path below
}
}
quickSnapInputRef.current?.click();
};
// Web file-input change → hand the chosen photo to the upload flow.
const handleQuickSnapInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file again still fires onChange.
e.target.value = '';
if (file) handleQuickCameraCapture(file);
};
// Handle quick camera capture — the whole point of Quick Snap is SPEED, so we
// do NOT block on the Blossom upload here (that needs an Amber signature and
// can be slow or fail — which previously left the user stranded after taking
// the photo). Instead: stash the raw photo, grab location, and navigate to the
// Add Spot form IMMEDIATELY. The form uploads the photo there.
const handleQuickCameraCapture = async (file: File) => {
try {
// Stash the captured photo as a data URL so the Create Spot page can pick
// it up and upload it in the right context.
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('Could not read photo'));
reader.readAsDataURL(file);
});
sessionStorage.setItem('quickCapturePendingPhoto', dataUrl);
// Clear any older uploaded-URL key so the form doesn't use a stale image.
sessionStorage.removeItem('quickCaptureImage');
} catch (err) {
console.warn('[QuickSnap] could not stash photo:', err);
}
// Try to grab location quickly, but never let it block navigation.
try {
const { lat, lng } = await getCurrentPosition();
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}&quickCapture=true`);
} catch {
navigate('/create-spot?quickCapture=true');
}
};
// Filter combined spots and shops
const filteredSpots = useMemo(() => {
let filtered = allLocations.filter((item) => {
@ -1232,7 +1151,7 @@ export default function MapPage() {
</div>
{/* Quick actions for logged in users.
Note: Add Spot / Shop / Tour / Quick Snap live in the
Note: Add Spot / Shop / Event / Gear / Tour live in the
map's "Add" speed-dial FAB — here we only surface the
shortcuts that aren't already on the map (Marketplace,
Stash) to avoid duplication. */}
@ -1803,19 +1722,6 @@ export default function MapPage() {
</span>
</Link>
))}
{/* Quick Snap — camera-first (native) / file-input (web) */}
<button
onClick={handleQuickSnap}
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ animationDelay: '200ms' }}
>
<span className="px-2.5 py-1 rounded-lg bg-black/85 backdrop-blur text-white text-xs font-bold shadow-lg border border-white/10">
Quick Snap
</span>
<span className="h-11 w-11 rounded-full flex items-center justify-center shadow-lg bg-black/80 border border-white/20">
<Camera className="h-4 w-4 text-[#ccff00]" />
</span>
</button>
</>
)}
@ -1828,7 +1734,7 @@ export default function MapPage() {
? 'bg-white rotate-45'
: 'bg-[#ff0040] shadow-[#ff0040]/40 hover:bg-[#ff0040]/90'
)}
aria-label={addDialOpen ? 'Close add menu' : 'Add spot, shop, tour, or snap'}
aria-label={addDialOpen ? 'Close add menu' : 'Add spot, shop, event, gear, or tour'}
>
<Plus className={cn('h-7 w-7 transition-colors', addDialOpen ? 'text-black' : 'text-white')} strokeWidth={2.5} />
</button>
@ -1900,18 +1806,6 @@ export default function MapPage() {
</div>
</div>
{/* Quick Snap — web fallback input (native uses the Camera plugin above).
`capture="environment"` hints the rear camera; browsers may honor it
or show a chooser. Single photo is all we need. */}
<input
ref={quickSnapInputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleQuickSnapInput}
/>
{/* Spot Bottom Sheet — slides up when a marker is tapped, map stays visible */}
<Sheet open={!!selectedSpot} onOpenChange={(open) => { if (!open) setSelectedSpot(null); }}>
<SheetContent

View file

@ -57,6 +57,11 @@ description: |
website: https://skate-spots.org
license: custom
# App icon (local path or URL). YAML config ALWAYS wins over the icon
# extracted from the APK or pulled from external metadata sources.
# This is the orange SkateSpots logo used everywhere else.
icon: ./public/images/icon-1024.png
# Tags for Zapstore discovery
tags:
- sports
@ -80,9 +85,10 @@ supported_nips:
# METADATA SOURCES
# ═══════════════════════════════════════════════════════════════════
# Pull icon + screenshots from Play Store (no GitHub needed)
metadata_sources:
- playstore
# Do NOT enrich icon/metadata from external sources — we set everything
# explicitly here so the correct orange logo is always used. Play Store
# enrichment was overriding our icon with the wrong (blue launcher) image.
metadata_sources: []
# ═══════════════════════════════════════════════════════════════════
# RELEASE NOTES