v1.2.0: Spedn label, multi-relay community stats, GrapheneOS Near-Me fix

1. Map filter: renamed the "Bitcoin" toggle to "Spedn" (matches the toolbar
   button); tooltip now says "Spedn your sats".

2. Community stats (/stats): useGrowthStats now queries the same relay set as the
   rest of the app (community relay + user relays) via useSkateRelays instead of
   only the single hardcoded SKATE_RELAY. Lab profiles / spots / etc. that live on
   a user's own relay are now counted, so the numbers match what /lab shows.

3. Near Me on GrapheneOS: even after granting the OS location permission, the
   WebView's navigator.geolocation stays blocked → "permission denied". Fixed by
   fetching the position from the native Capacitor Geolocation plugin first
   (LocationManager, honours the granted permission), falling back to
   navigator.geolocation only if that fails. Also auto-inject
   ACCESS_FINE/COARSE_LOCATION into AndroidManifest via the manifest script.

Bump version to 1.2.0 (versionCode 10200).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 14:54:00 +07:00
parent 6fb7314f6c
commit 677ccb8a82
5 changed files with 96 additions and 41 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.1.9", "version": "1.2.0",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

View file

@ -29,13 +29,17 @@ if (!existsSync(manifestPath)) {
} }
let manifest = readFileSync(manifestPath, 'utf8'); let manifest = readFileSync(manifestPath, 'utf8');
let changed = false;
if (manifest.includes('android:scheme="nostrsigner"')) { // Insert right before the closing </manifest> tag.
console.log('[inject-amber-queries] <queries> already present — nothing to do.'); if (!manifest.includes('</manifest>')) {
process.exit(0); console.error('[inject-amber-queries] No closing </manifest> tag found — aborting.');
process.exit(1);
} }
const queriesBlock = ` <!-- REQUIRED for Amber (NIP-55) on Android 11+ / GrapheneOS. // ── 1. Amber <queries> block ────────────────────────────────────────────────
if (!manifest.includes('android:scheme="nostrsigner"')) {
const queriesBlock = ` <!-- REQUIRED for Amber (NIP-55) on Android 11+ / GrapheneOS.
Without this the OS blocks the nostrsigner intent and the signer call Without this the OS blocks the nostrsigner intent and the signer call
hangs forever ("Waiting for Amber"). Injected by scripts/inject-amber-queries.mjs --> hangs forever ("Waiting for Amber"). Injected by scripts/inject-amber-queries.mjs -->
<queries> <queries>
@ -46,14 +50,31 @@ const queriesBlock = ` <!-- REQUIRED for Amber (NIP-55) on Android 11+ / Grap
<package android:name="com.greenart7c3.nostrsigner" /> <package android:name="com.greenart7c3.nostrsigner" />
</queries> </queries>
`; `;
manifest = manifest.replace('</manifest>', `${queriesBlock}</manifest>`);
// Insert right before the closing </manifest> tag. changed = true;
if (!manifest.includes('</manifest>')) { console.log('[inject-amber-queries] ✓ Injected Amber <queries> block.');
console.error('[inject-amber-queries] No closing </manifest> tag found — aborting.'); } else {
process.exit(1); console.log('[inject-amber-queries] <queries> already present.');
} }
manifest = manifest.replace('</manifest>', `${queriesBlock}</manifest>`); // ── 2. Location permissions ───────────────────────────────────────────────────
writeFileSync(manifestPath, manifest); // The native Geolocation plugin can't prompt for / obtain a fix without these.
// Missing permissions is why "Near Me" reports denied even after the user
// thinks they granted it.
const locationPerms = ` <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
`;
if (!manifest.includes('ACCESS_FINE_LOCATION')) {
manifest = manifest.replace('</manifest>', `${locationPerms}</manifest>`);
changed = true;
console.log('[inject-amber-queries] ✓ Injected location permissions.');
} else {
console.log('[inject-amber-queries] location permissions already present.');
}
console.log('[inject-amber-queries] ✓ Injected Amber <queries> block into AndroidManifest.xml'); if (changed) {
writeFileSync(manifestPath, manifest);
console.log('[inject-amber-queries] AndroidManifest.xml updated.');
} else {
console.log('[inject-amber-queries] nothing to do.');
}

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { import {
SKATE_SPOT_KIND, SKATE_SPOT_KIND,
SKATE_SHOP_KIND, SKATE_SHOP_KIND,
@ -16,7 +16,6 @@ import {
SKATE_MUSICIAN_KIND, SKATE_MUSICIAN_KIND,
SKATE_TOUR_KIND, SKATE_TOUR_KIND,
SKATE_CREW_KIND, SKATE_CREW_KIND,
SKATE_RELAY,
} from '@/lib/types'; } from '@/lib/types';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@ -112,12 +111,15 @@ function fillMonthGaps(keys: string[]): string[] {
// ─── Hook ──────────────────────────────────────────────────────────────────── // ─── Hook ────────────────────────────────────────────────────────────────────
export function useGrowthStats() { export function useGrowthStats() {
const { nostr } = useNostr(); // Query the SAME relay set the rest of the app uses (community relay + the
// user's relays), not just the single hardcoded community relay. Otherwise
// Lab profiles (and spots/shops/etc.) that live on a user's own relay show up
// on /lab but are missing from the community stats counts.
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<GrowthStats>({ return useQuery<GrowthStats>({
queryKey: ['growth-stats'], queryKey: ['growth-stats', getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const relay = nostr.relay(SKATE_RELAY);
const signal = AbortSignal.timeout(15000); const signal = AbortSignal.timeout(15000);
// Query all content types in parallel from the community relay. // Query all content types in parallel from the community relay.
@ -135,19 +137,19 @@ export function useGrowthStats() {
crewEvents, crewEvents,
otherLabEvents, otherLabEvents,
] = await Promise.all([ ] = await Promise.all([
relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [CALENDAR_EVENT_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []), querySkateContent([{ kinds: [CALENDAR_EVENT_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_PHOTOGRAPHER_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_PHOTOGRAPHER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
// New Lab crafts (artist, graffiti, tattoo, trainer, musician) — folded // New Lab crafts (artist, graffiti, tattoo, trainer, musician) — folded
// into community totals/mappers without their own chart series. // into community totals/mappers without their own chart series.
relay.query([{ kinds: [SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND], limit: 1000 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND], limit: 1000 }], { signal }).catch(() => []),
]); ]);
// Deduplicate addressable events (keep newest per pubkey+kind+d) // Deduplicate addressable events (keep newest per pubkey+kind+d)

View file

@ -63,15 +63,24 @@ interface CapPermissionStatus {
location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'; location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'; coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
} }
interface CapPosition {
coords: { latitude: number; longitude: number };
}
/** /**
* We deliberately only type the PERMISSION methods. The position methods * Permission methods are always safe. getCurrentPosition is attempted (safely
* (getCurrentPosition/watchPosition) are NOT used on native because they route * wrapped) because on GrapheneOS the WebView's navigator.geolocation is blocked
* through Google Play Services and crash de-Googled devices — see * even after the OS permission is granted — so the native plugin is the only
* getNativePosition below, which fetches the position via navigator.geolocation. * way to actually get a fix there. Recent @capacitor/geolocation falls back to
* Android LocationManager when Play Services is absent, so it no longer crashes.
*/ */
interface CapGeolocationPlugin { interface CapGeolocationPlugin {
checkPermissions(): Promise<CapPermissionStatus>; checkPermissions(): Promise<CapPermissionStatus>;
requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>; requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>;
getCurrentPosition(options?: {
enableHighAccuracy?: boolean;
timeout?: number;
maximumAge?: number;
}): Promise<CapPosition>;
} }
/** /**
@ -202,11 +211,34 @@ async function getNativePosition(native: CapGeolocationPlugin): Promise<GeoCoord
throw new GeolocationError('denied', 'Location permission denied.'); throw new GeolocationError('denied', 'Location permission denied.');
} }
// Permission handled natively — now fetch the position via the WebView's // Permission granted natively. Now get the actual position.
// navigator.geolocation (Android LocationManager, no Play Services). This is //
// what avoids the fatal Play-Services crash on GrapheneOS while still // On GrapheneOS the WebView's navigator.geolocation is BLOCKED even after the
// working normally on stock Android (the WebView shares the app's granted // OS-level permission is granted (the WebView has its own geolocation gate
// location permission). // that Capacitor doesn't open) → navigator.geolocation returns
// PERMISSION_DENIED, which is the "granted but still denied" bug users hit.
//
// So we ask the NATIVE plugin for the position first — it talks to Android's
// LocationManager directly and honours the permission we just granted.
// Modern @capacitor/geolocation degrades to LocationManager when Play
// Services is absent, so this no longer crashes de-Googled devices. We wrap
// it defensively and fall back to navigator.geolocation only if it fails.
try {
const pos = await safeCall(() =>
native.getCurrentPosition({ enableHighAccuracy: false, timeout: 15000, maximumAge: 60000 }),
);
const lat = pos?.coords?.latitude;
const lng = pos?.coords?.longitude;
if (typeof lat === 'number' && typeof lng === 'number' && !(lat === 0 && lng === 0)) {
return { lat, lng };
}
// Bad/empty reading — fall through to the web path.
} catch (nativePosErr) {
console.warn('[geolocation] native getCurrentPosition failed, falling back to WebView:', nativePosErr);
}
// Fallback: WebView navigator.geolocation (works on stock Android, shares
// the app's granted permission).
return await getWebPosition(); return await getWebPosition();
} catch (err) { } catch (err) {
if (err instanceof GeolocationError) throw err; if (err instanceof GeolocationError) throw err;

View file

@ -1086,7 +1086,7 @@ export default function MapPage() {
)} )}
onClick={() => setShowBtcMap(!showBtcMap)} onClick={() => setShowBtcMap(!showBtcMap)}
> >
<Bitcoin className="h-3 w-3" /> Bitcoin <Bitcoin className="h-3 w-3" /> Spedn
</Button> </Button>
</div> </div>
</div> </div>
@ -1282,8 +1282,8 @@ export default function MapPage() {
<TooltipContent side="bottom" className="max-w-[200px]"> <TooltipContent side="bottom" className="max-w-[200px]">
<p className="text-xs"> <p className="text-xs">
{showBtcMap {showBtcMap
? `Showing ${btcMapPlaces?.length || 0} Bitcoin-accepting places nearby` ? `Showing ${btcMapPlaces?.length || 0} places where you can Spedn your sats nearby`
: 'Show places where you can spend your sats (via BTC Map)' : 'Show places where you can Spedn your sats (via BTC Map)'
} }
</p> </p>
</TooltipContent> </TooltipContent>