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:
parent
f836763e75
commit
a83611072a
4 changed files with 99 additions and 186 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue