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",
"private": true,
"version": "1.1.9",
"version": "1.2.0",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -29,13 +29,17 @@ if (!existsSync(manifestPath)) {
}
let manifest = readFileSync(manifestPath, 'utf8');
let changed = false;
if (manifest.includes('android:scheme="nostrsigner"')) {
console.log('[inject-amber-queries] <queries> already present — nothing to do.');
process.exit(0);
// Insert right before the closing </manifest> tag.
if (!manifest.includes('</manifest>')) {
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
hangs forever ("Waiting for Amber"). Injected by scripts/inject-amber-queries.mjs -->
<queries>
@ -46,14 +50,31 @@ const queriesBlock = ` <!-- REQUIRED for Amber (NIP-55) on Android 11+ / Grap
<package android:name="com.greenart7c3.nostrsigner" />
</queries>
`;
// Insert right before the closing </manifest> tag.
if (!manifest.includes('</manifest>')) {
console.error('[inject-amber-queries] No closing </manifest> tag found — aborting.');
process.exit(1);
manifest = manifest.replace('</manifest>', `${queriesBlock}</manifest>`);
changed = true;
console.log('[inject-amber-queries] ✓ Injected Amber <queries> block.');
} else {
console.log('[inject-amber-queries] <queries> already present.');
}
manifest = manifest.replace('</manifest>', `${queriesBlock}</manifest>`);
writeFileSync(manifestPath, manifest);
// ── 2. Location permissions ───────────────────────────────────────────────────
// 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 { useNostr } from '@nostrify/react';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import {
SKATE_SPOT_KIND,
SKATE_SHOP_KIND,
@ -16,7 +16,6 @@ import {
SKATE_MUSICIAN_KIND,
SKATE_TOUR_KIND,
SKATE_CREW_KIND,
SKATE_RELAY,
} from '@/lib/types';
// ─── Types ───────────────────────────────────────────────────────────────────
@ -112,12 +111,15 @@ function fillMonthGaps(keys: string[]): string[] {
// ─── Hook ────────────────────────────────────────────────────────────────────
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>({
queryKey: ['growth-stats'],
queryKey: ['growth-stats', getQueryKeySuffix()],
queryFn: async () => {
const relay = nostr.relay(SKATE_RELAY);
const signal = AbortSignal.timeout(15000);
// Query all content types in parallel from the community relay.
@ -135,19 +137,19 @@ export function useGrowthStats() {
crewEvents,
otherLabEvents,
] = await Promise.all([
relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [CALENDAR_EVENT_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_PHOTOGRAPHER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [CALENDAR_EVENT_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_PHOTOGRAPHER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
// New Lab crafts (artist, graffiti, tattoo, trainer, musician) — folded
// 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)

View file

@ -63,15 +63,24 @@ interface CapPermissionStatus {
location: '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
* (getCurrentPosition/watchPosition) are NOT used on native because they route
* through Google Play Services and crash de-Googled devices — see
* getNativePosition below, which fetches the position via navigator.geolocation.
* Permission methods are always safe. getCurrentPosition is attempted (safely
* wrapped) because on GrapheneOS the WebView's navigator.geolocation is blocked
* even after the OS permission is granted — so the native plugin is the only
* 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 {
checkPermissions(): 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.');
}
// Permission handled natively — now fetch the position via the WebView's
// navigator.geolocation (Android LocationManager, no Play Services). This is
// what avoids the fatal Play-Services crash on GrapheneOS while still
// working normally on stock Android (the WebView shares the app's granted
// location permission).
// Permission granted natively. Now get the actual position.
//
// On GrapheneOS the WebView's navigator.geolocation is BLOCKED even after the
// OS-level permission is granted (the WebView has its own geolocation gate
// 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();
} catch (err) {
if (err instanceof GeolocationError) throw err;

View file

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