Compare commits
96 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f4fa29d55 | ||
|
|
4e7faed781 | ||
|
|
68119eb927 | ||
|
|
579dcddc1d | ||
|
|
f75bad3e8d | ||
|
|
4c4368654a | ||
|
|
c352986e70 | ||
|
|
11171b37da | ||
|
|
62558efa59 | ||
|
|
1af3a5f6ef | ||
|
|
9732743527 | ||
|
|
50d82d5a05 | ||
|
|
2a52d10f26 | ||
|
|
327e81f155 | ||
|
|
3f28c3fdb4 | ||
|
|
ab90e02f69 | ||
|
|
d0a593ee67 | ||
|
|
5e90b828b4 | ||
|
|
fed2ff135b | ||
|
|
722ca1bc7e | ||
|
|
66928b2bca | ||
|
|
4c603a8126 | ||
|
|
a42e85202f | ||
|
|
b71ff5b08f | ||
|
|
c846dcc267 | ||
|
|
03d13c0175 | ||
|
|
2e904c287e | ||
|
|
5bb5b5efc6 | ||
|
|
d080ca75f4 | ||
|
|
c21d1b097d | ||
|
|
4e44ce769b | ||
|
|
7e8d13cbff | ||
|
|
6ec5a9f024 | ||
|
|
f7316060ae | ||
|
|
ae1d7275e6 | ||
|
|
719a6791f9 | ||
|
|
53380a99e1 | ||
|
|
807b48bafc | ||
|
|
78741f9517 | ||
|
|
7b2fd3993a | ||
|
|
b180d136c3 | ||
|
|
42ffcb1084 | ||
|
|
f041399fe9 | ||
|
|
56d318811f | ||
|
|
0859327464 | ||
|
|
53cad29c7e | ||
|
|
5acaa3b2b4 | ||
|
|
226d0c3f95 | ||
|
|
f646d5aa14 | ||
|
|
0fe82c28d8 | ||
|
|
20de6ba8e1 | ||
|
|
bcc277a054 | ||
|
|
c8f471b290 | ||
|
|
c2750eae85 | ||
|
|
609b15be08 | ||
|
|
cc0545cc5c | ||
|
|
31d1630c50 | ||
|
|
1d810ea964 | ||
|
|
a813e1e7f7 | ||
|
|
618d961f72 | ||
|
|
e1a011045a | ||
|
|
a44665f05f | ||
|
|
4392df3202 | ||
|
|
99f04edf5d | ||
|
|
3edef9cbdf | ||
|
|
42d25818b4 | ||
|
|
5a1d849681 | ||
|
|
b4fbae693b | ||
|
|
c08d5d4baa | ||
|
|
2c8bd594a4 | ||
|
|
9ce57bedf9 | ||
|
|
bfc777c18b | ||
|
|
a2b732317e | ||
|
|
5ebf741427 | ||
|
|
4ae30ee8e6 | ||
|
|
72e5f89b49 | ||
|
|
d003748bb1 | ||
|
|
6048eafc0d | ||
|
|
562a7de7c0 | ||
|
|
8b12001fa6 | ||
|
|
383ff8a5cf | ||
|
|
65914144d1 | ||
|
|
a8780d5236 | ||
|
|
2dcf5a2aad | ||
|
|
50c95d0ff7 | ||
|
|
4ff736734d | ||
|
|
fd1713f6b1 | ||
|
|
6c9b6fab02 | ||
|
|
0ae4c59630 | ||
|
|
ab11b0d3ed | ||
|
|
0355c10dbc | ||
|
|
28d34e3fbe | ||
|
|
b0df599b52 | ||
|
|
74bd140295 | ||
|
|
42f47304dd | ||
|
|
c00bffdf78 |
3
.gitignore
vendored
|
|
@ -52,7 +52,8 @@ stats.json
|
|||
|
||||
# Local/generated assets & incidental lockfiles (do not commit)
|
||||
/assets/
|
||||
/resources/
|
||||
/resources/*
|
||||
!/resources/splash.png
|
||||
/bun.lock
|
||||
cvm-server/bun.lock
|
||||
push-service/bun.lock
|
||||
|
|
|
|||
225
AGENTS.md
|
|
@ -1418,6 +1418,24 @@ session with **no other memory of this project**. So:
|
|||
it in when known.
|
||||
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
|
||||
**`cordn`** field (app convention, documented in NIP.md — not a new kind).
|
||||
**Crew-owned coordinator kit (2026-10-31, install helper 2026-11-01):**
|
||||
`deploy/cordn/install.sh` is the one-command installer (generates `.env`,
|
||||
starts SQLite, prints nprofile/add link). `deploy/cordn/` remains the
|
||||
production hand-off for crews: Docker Compose, SQLite volume, `.env` template,
|
||||
backup/restore scripts and a runbook. Cordn connects **outbound** to ContextVM
|
||||
relays; no inbound port, domain or TLS terminator is required for the
|
||||
coordinator itself. The app still does **not** embed a coordinator. Settings →
|
||||
Coordinator links to the kit; the crew's `coordinator` tag/operator remains
|
||||
the adoption path. Keep the coordinator key separate from crew/admin keys.
|
||||
Do not claim multi-coordinator failover; this is one trusted delivery service
|
||||
with backups, not BFT.
|
||||
**Custom coordinator routing (2026-11-01):** `useCordnNative` treats a crew's
|
||||
own coordinator as authoritative. A custom URL without a service pubkey is
|
||||
rejected instead of silently falling back to the SkateSpots coordinator. The
|
||||
join watcher groups crews by coordinator pubkey/relay and polls each one; a
|
||||
custom coordinator being offline does not stop notifications for other crews.
|
||||
Device-link creation now shows an inline error/retry instead of a permanent
|
||||
loading dialog.
|
||||
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
|
||||
http URL), **normalises a bare pointer to Cordn's canonical add-link** before
|
||||
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
|
||||
|
|
@ -1781,6 +1799,17 @@ session with **no other memory of this project**. So:
|
|||
`public/images/screenshots/{map,spot,community}.png` (1280×800, quantised —
|
||||
captured with headless Chromium; regenerate with a CDP harness if the UI
|
||||
changes materially).
|
||||
- **Nsec storage hardening (2026-11-01):** `src/lib/nostrLogin.ts` replaces the
|
||||
package login provider. Non-secret logins stay in localStorage; **nsec logins
|
||||
are sessionStorage-only** and are also stripped from localStorage on the next
|
||||
write. A tab reload keeps the nsec session; closing the browser/app requires
|
||||
re-entry. Use Amber or a bunker to stay signed in. `src/lib/nostrLogin.test.tsx`
|
||||
asserts raw nsec never lands in persistent localStorage. Do not import
|
||||
`@nostrify/react/login` directly again — use `@/lib/nostrLogin`.
|
||||
- **Direct-link boot rewrite (2026-11-01):** `main.tsx` now rewrites any
|
||||
non-root pathname without a hash into `/#<path>` before React mounts, so
|
||||
`/spot/...`, `/crew/...`, `/event/...`, `/listing/...` and cold native App
|
||||
Links do not silently land on the map.
|
||||
- **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native
|
||||
auth upgrades (owner picked these two; read-only/npub sessions were explicitly
|
||||
declined).
|
||||
|
|
@ -2228,7 +2257,7 @@ session with **no other memory of this project**. So:
|
|||
instead of showing the error boundary.
|
||||
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
|
||||
**156 tests**, build ok. The gate also type-checks the CVM server
|
||||
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.56**.
|
||||
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.79**.
|
||||
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
||||
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||||
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
||||
|
|
@ -2376,7 +2405,7 @@ The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped o
|
|||
### Android release build
|
||||
- `npm run android:build` → `android/app/build/outputs/apk/release/app-release.apk`.
|
||||
- **Versioning**: `package.json` `version` is the single source of truth; `scripts/sync-android-version.mjs` syncs it to `versionName`/`versionCode` (e.g. `1.4.5` → `10405`) during the build. Bump **only** `package.json`.
|
||||
- **Signing**: secrets live in the gitignored `keystore.properties`; `scripts/inject-signing-config.mjs` injects a managed block into `android/app/build.gradle` (runs in `android:init`/`android:sync`/`android:build`). The authoritative keystore is **`/home/sovereignsk8/skatespots-release.jks`** (alias `skatespots`). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`.
|
||||
- **Signing**: secrets live **outside the repo** at `~/.config/skatespots/android/keystore.properties`; `scripts/inject-signing-config.mjs` injects a managed block into `android/app/build.gradle` (runs in `android:init`/`android:sync`/`android:build`). The authoritative keystore is **`~/.config/skatespots/android/skatespots-release.jks`** (alias `skatespots`, mode 600). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`. Never put signing material back in the repo: Vite serves files by filesystem path, not by `.gitignore`.
|
||||
- `android/` is **generated and gitignored**; recreate with `npm run android:init`.
|
||||
- **Launch splash:** `resources/splash.png` is the orange brand wheel. Capacitor's
|
||||
generated drawables used a stock blue placeholder, so `scripts/make-android-splash.mjs`
|
||||
|
|
@ -2415,6 +2444,95 @@ Rules:
|
|||
- Any time the deploy output isn't verified by the smoke test, run
|
||||
`node scripts/smoke-test.mjs` manually before shipping.
|
||||
|
||||
### Trust, privacy and consistency invariants
|
||||
- **No BFT / no relay quorum.** Nostr events are signed facts; relays are not
|
||||
voters. Social feeds resolve conflicts locally, not by consensus.
|
||||
- **Deterministic merge:** `src/lib/eventMerge.ts` is the single merge policy
|
||||
(newest `created_at`, event-id tie-break, local tombstones). Do not add
|
||||
"first relay wins" logic; relay arrival order must not change the result.
|
||||
- **Trust boundaries are documented** in `docs/THREAT_MODEL.md`. Keep UI copy
|
||||
honest: a confirmed trade means both parties signed; a payment proof means the
|
||||
money leg was paid; escrow is time-locked, not arbitration.
|
||||
- **No remote hotlinks for small brand assets:** OpenSats icons are local under
|
||||
`public/images/` so rendering the footer does not leak the viewer's IP.
|
||||
- **Android Auto Backup is off** (`android:allowBackup="false"` injected by
|
||||
`scripts/inject-backup-policy.mjs`); identity/settings/escrow state stays on
|
||||
the device and must be exported explicitly.
|
||||
- **Image uploads re-encode decodable formats through canvas** to strip
|
||||
EXIF/GPS before Blossom. GIF and HEIC/HEIF are passed through because they need
|
||||
special handling; do not claim they are metadata-stripped.
|
||||
- **No mandatory identity, deposit or global trust score.** Use warnings,
|
||||
explicit consent and optional escrow at value boundaries.
|
||||
|
||||
### Distribution without GitHub
|
||||
> **Owner decision (2026-10-30): no external identities.** Distribution must
|
||||
> stay self-hosted and Nostr-based. Do **not** create or ask for GitLab,
|
||||
> Codeberg, GitHub, email or F-Droid-main accounts. The local Forgejo admin
|
||||
> account and the self-hosted F-Droid repo key are infrastructure credentials,
|
||||
> not third-party identities. F-Droid main and IzzyOnDroid are therefore
|
||||
> **out of scope**.
|
||||
|
||||
- **Self-hosted Forgejo:** `https://git.skate-spots.org/skatespots/SkateSpots`
|
||||
(public source, registration disabled, admin credentials only on the relay
|
||||
host). It runs on `185.18.221.2` behind Caddy; do not commit its credentials.
|
||||
- **Latest release (Obtanium HTML source):**
|
||||
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`.
|
||||
The page exposes one signed `.apk`; Obtanium may need **Override Source →
|
||||
HTML** because the host is self-hosted, not Codeberg.
|
||||
- **Static download page:** `public/download/index.html` → `https://skate-spots.org/download/`.
|
||||
- **Forgejo release helper:** `scripts/publish-forgejo.sh` (uses `FORGEJO_TOKEN`
|
||||
or `FORGEJO_PASSWORD`; `scripts/release.sh` calls it automatically when either
|
||||
is set). It uploads the APK and checksum, but does not push git history.
|
||||
- **Self-hosted F-Droid repo:** `https://fdroid.skate-spots.org/repo`
|
||||
(`org.skatespots.app`; currently 1.7.79). It is a **third-party repo**, not
|
||||
F-Droid main. Repo signing fingerprint:
|
||||
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`.
|
||||
The developer APK signature is unchanged; the repo key only signs indexes.
|
||||
A backup of the repo keystore/config is in
|
||||
`~/.config/skatespots/fdroid-repo/` (mode 600).
|
||||
- **F-Droid main/IzzyOnDroid:** **out of scope by owner decision.** Both require
|
||||
external GitLab/Codeberg identities and review; do not submit, create accounts,
|
||||
or present them as live. The self-hosted F-Droid repo above is the supported
|
||||
F-Droid-family channel.
|
||||
- **No GitHub required.** Keep Zapstore, Forgejo, the self-hosted F-Droid repo,
|
||||
and the static page in sync for each release.
|
||||
|
||||
### Future release / distribution checklist
|
||||
1. Bump `package.json`; update the top of `release-notes.txt`; run
|
||||
`scripts/release.sh patch --yes --deploy`.
|
||||
2. Set `FORGEJO_TOKEN` (or `FORGEJO_PASSWORD`) to upload the APK/checksum to the
|
||||
latest Forgejo release; the helper is `scripts/publish-forgejo.sh`.
|
||||
3. **Push source/tags to Forgejo.** The local machine's DNS for
|
||||
`git.skate-spots.org` is intermittent, so the reliable path is: create a git
|
||||
bundle locally, `scp` it to the relay, clone there, and push to
|
||||
`http://127.0.0.1:3000/skatespots/SkateSpots.git` with the host admin
|
||||
credentials. Do not put the password in a committed remote URL.
|
||||
4. Add the APK to `/home/ubuntu/fdroid/repo/`, update the F-Droid metadata
|
||||
changelog, run `fdroid update` in `/home/ubuntu/fdroid`, then rsync `repo/`
|
||||
to `/var/www/fdroid/repo/`.
|
||||
5. Update `public/download/index.html` with the new version/checksum and deploy
|
||||
the web page.
|
||||
6. Verify: latest Forgejo release exposes one `.apk`; static page 200;
|
||||
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
|
||||
`versionCode`; Obtanium still resolves the latest release.
|
||||
7. Keep Forgejo/F-Droid credentials and the F-Droid repo keystore out of git.
|
||||
Local backups: `~/.config/skatespots/forgejo-admin.txt` and
|
||||
`~/.config/skatespots/fdroid-repo/` (mode 600).
|
||||
|
||||
### Nostr-only distribution (no external identities)
|
||||
- **Zapstore** is the Nostr-native app store/update channel; keep publishing
|
||||
release events there.
|
||||
- **Obtanium** is URL-based, not an account: users add the latest Forgejo
|
||||
release URL (HTML source if needed) or the self-hosted F-Droid repo.
|
||||
- **F-Droid main and IzzyOnDroid do not support Nostr identities.** They are
|
||||
intentionally not pursued. Do not create or request GitLab/Codeberg/email
|
||||
accounts, and do not claim those stores are live.
|
||||
- The local Forgejo admin account, the relay, and the F-Droid repo signing key
|
||||
are self-hosted infrastructure, not third-party identities.
|
||||
- The F-Droid recipe remains in `distribution/fdroiddata/` only as an unused
|
||||
technical reference. It was locally test-built at `5ebf741`, but must not be
|
||||
submitted.
|
||||
|
||||
### Deploy
|
||||
- **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`.
|
||||
- ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately does **not** record the deployed SHA, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately.
|
||||
|
|
@ -2424,6 +2542,10 @@ Rules:
|
|||
- Flags: `--probe-push` runs a bounded 45s **real** `git push` — signing only happens when there *is* something to push, so this is the only true signer probe (it publishes any unpushed commits; a timeout ⇒ signer down ⇒ open Amber in the foreground, or `ngit account login --interactive`). `--notify` = desktop notification (deduped by an actionable-problem signature). `--quiet` = silent when healthy. Test against a fake state dir with `SKATESPOTS_STATE_DIR=…`.
|
||||
- ⚠️ `git push --dry-run` does **NOT** exercise signing (prints "Everything up-to-date", never signs) — do not use it as a signer probe.
|
||||
- **Runs automatically every 15 min:** `skatespots-ops-health.timer` → `skatespots-ops-health.service` (`--quiet --notify`). Install/refresh with `scripts/install-ops-health-timer.sh`; logs `journalctl --user -u skatespots-ops-health`.
|
||||
- **Community bot check is conservative:** a failed SSH probe now reports
|
||||
“state unknown” and does **not** notify. Only an actual `inactive`/`failed`
|
||||
systemd state is actionable. This stops transient SSH/DNS hiccups from
|
||||
producing “community Cordn bot is unreachable” notifications.
|
||||
- **Web (manual)**: `npm run build` produces the web `dist/` (with `_headers`/`_redirects`); deploy by dragging `dist/` to the Netlify site's Deploys tab.
|
||||
- **Zapstore (Android)**, from the repo root: `SIGN_WITH='bunker://...' zsp publish zapstore.yaml`
|
||||
- `zsp publish --wizard` is **broken for this app** (the wizard can't parse a nostrhub web `repository` — it supports only GitHub/GitLab/Gitea/F-Droid/local). Always pass `zapstore.yaml`.
|
||||
|
|
@ -2781,6 +2903,93 @@ Rules:
|
|||
Mints and all secondary sections moved to More. Customizer edits three
|
||||
middle slots; desktop default primary tabs use Community instead of Mints.
|
||||
APK sha256 `ec15b80d…`, versionCode 10756. Web live at `cdf4a63`.
|
||||
- **1.7.57 (2026-10-30):** mobile header avatar opens the user’s public
|
||||
profile; the adjacent chevron opens the account menu. Removed the
|
||||
redundant “IN” label so the header fits at 390px. APK sha256 `846efb24…`,
|
||||
versionCode 10757. Web live at `5026b63`.
|
||||
- **1.7.58 (2026-10-30):** the top-left SkateSpots brand lockup opens the
|
||||
signed-in user’s profile on mobile and desktop; signed out it still opens
|
||||
the map. Bottom Spots / desktop Map remains the home shortcut. APK sha256
|
||||
`0d7bdad7…`, versionCode 10758. Web live at `74bd140`.
|
||||
- **1.7.59 (2026-10-30):** owner/admin Delete in spot/shop card menus; mobile
|
||||
Back arrow on every non-map screen (history fallback to map); avatar account
|
||||
menu trimmed to status/profile/settings/accounts/logout; brand wordmark
|
||||
hidden on sub-pages for narrow-header fit. APK sha256 `0fd4a149…`,
|
||||
versionCode 10759. Web live at `0355c10`.
|
||||
- **1.7.60 (2026-10-30):** Cordn header shows last successful check; sellers
|
||||
see payment-readiness in Create Listing + offers; uploads resize photos to
|
||||
1600px WebP when smaller (GIF/undecodable formats untouched), cutting
|
||||
quota/storage/upload time. APK sha256 `8f11c229…`, versionCode 10760.
|
||||
Web live at `6c9b6fa`.
|
||||
- **1.7.61 (2026-10-30):** Cordn setup timeline now checks prepared device
|
||||
/sent request and spins on the approval step while waiting. APK sha256
|
||||
`db0279f7…`, versionCode 10761. Web live at `50c95d0`.
|
||||
- **1.7.62 (2026-10-31):** 404 uses an in-app link; profile badge URLs are
|
||||
sanitized; marketplace readiness only counts a usable lud16; duplicate
|
||||
Korean picker entry hidden; beta source link points self-hosted. APK
|
||||
sha256 `e746b18e…`, versionCode 10762. Web live at `c08d5d4`.
|
||||
- **1.7.63 (2026-10-31):** deterministic event merge with event-id tie-break
|
||||
and local deletion tombstones; EXIF/GPS stripping for decodable uploads;
|
||||
Android Auto Backup disabled; local OpenSats icon; threat-model document.
|
||||
APK sha256 `70e1dd1a…`, versionCode 10763. Web live at `3edef9c`.
|
||||
- **1.7.64 (2026-10-31):** low-friction new-seller safety note beside trade
|
||||
actions, using existing marketplace guidance; no new gate. APK sha256
|
||||
`32d5cd35…`, versionCode 10764. Web live at `a44665f`.
|
||||
- **1.7.65 (2026-10-31):** Settings relay-consistency diagnostic (profiles,
|
||||
on demand, no merge); recent 90-day confirmed-trade count beside lifetime
|
||||
reputation. APK sha256 `73684085…`, versionCode 10765. Web live at
|
||||
`a813e1e`.
|
||||
- **1.7.66 (2026-10-31):** high-value safety copy (no arbitration, escrow),
|
||||
180-day half-life reputation weighting, and the unit-tested 2-of-3 escrow
|
||||
policy primitive (not exposed in UI pending arbiter policy). APK sha256
|
||||
`0ed775ed…`, versionCode 10766. Web live at `cc0545c`.
|
||||
- **1.7.67 (2026-10-31):** crew-owned Cordn coordinator deployment kit
|
||||
(`deploy/cordn/`: Compose, SQLite volume, env template, backup/restore,
|
||||
runbook); persistent Docker command and links from Settings/Crew edit.
|
||||
No embedded coordinator, no BFT claim. APK sha256 `ac7c9589…`, versionCode
|
||||
10767. Web live at `c8f471b`.
|
||||
- **1.7.68 (2026-10-31):** real ContextVM RPC coordinator health probe with
|
||||
throwaway key, and optional crew coordinator operator field. APK sha256
|
||||
`d8222789…`, versionCode 10768. Web live at `0fe82c2`.
|
||||
- **1.7.69 (2026-10-31):** desktop status control is a compact status
|
||||
emoji/label chip (no large outlined pill or permanent ping). APK sha256
|
||||
`45add6a3…`, versionCode 10769. Web live at `226d0c3`.
|
||||
- **1.7.70 (2026-10-31):** Amber inbox decrypt gate (explicit Unlock card),
|
||||
10-minute expiry for redirect pending ops, and no plaintext/crypto results
|
||||
left in browser storage. APK sha256 `be521c16…`, versionCode 10770. Web
|
||||
live at `0859327`.
|
||||
- **1.7.71 (2026-11-01):** desktop status control is a compact emoji-only
|
||||
chip with a tooltip; mobile avatar duplicate online dot removed. APK sha256
|
||||
`5334be69…`, versionCode 10771. Web live at `42ffcb1`.
|
||||
- **1.7.72 (2026-11-01):** relay events are signature-verified before the
|
||||
shared merge path; image/avatar requests and the site referrer policy use
|
||||
no-referrer. APK sha256 `c2169cae…`, versionCode 10772. Web live at
|
||||
`78741f9`.
|
||||
- **1.7.73 (2026-11-01):** login dialog now has two adaptive methods:
|
||||
Amber on Android, NIP-46 QR on desktop, with bunker paste as a secondary
|
||||
path and no duplicate NIP-46 card. APK sha256 `bcd51861…`, versionCode
|
||||
10773. Web live at `719a679`.
|
||||
- **1.7.74 (2026-11-01):** one-command crew coordinator installer
|
||||
(`deploy/cordn/install.sh`), persistent SQLite + nprofile output; Settings
|
||||
shows the installer command. Phone host limitation documented. APK sha256
|
||||
`da0914b9…`, versionCode 10774. Web live at `6ec5a9f`.
|
||||
- **1.7.75 (2026-11-01):** crew coordinator routing is authoritative (no
|
||||
silent fallback to SkateSpots); per-coordinator join notifications; device
|
||||
link errors show retry. APK sha256 `530041f1…`, versionCode 10775. Web
|
||||
live at `c21d1b0`.
|
||||
- **1.7.76 (2026-11-01):** PWA 192/512/1024 icons, 512 maskable, 180 Apple
|
||||
touch icon; signup QR print popup uses 100dvh. APK sha256 `175ab9d1…`,
|
||||
versionCode 10776. Web live at `2e904c2`.
|
||||
- **1.7.77 (2026-11-01):** wallet flow simplified: no brand directory,
|
||||
hidden WebLN when absent, one connect CTA, generic help link. APK sha256
|
||||
`47eb0a54…`, versionCode 10777. Web live at `b71ff5b`.
|
||||
- **1.7.78 (2026-11-01):** native Wallet page/manager leads with built-in
|
||||
Cashu wallet; slightly larger mobile nav labels. APK sha256 `661ef822…`,
|
||||
versionCode 10778. Web live at `66928b2`.
|
||||
- **1.7.79 (2026-11-01):** nsec logins are sessionStorage-only and stripped
|
||||
from persistent localStorage; direct non-root paths rewrite into
|
||||
HashRouter before mount. APK sha256 `8fdf506b…`, versionCode 10779. Web
|
||||
live at `d0a593e`.
|
||||
|
||||
### Relay archive (dataset backup)
|
||||
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
||||
|
|
@ -3222,7 +3431,7 @@ The community relay moved off the hosted relay.tools instance
|
|||
- `git push` may print a benign `cannot lock ref ... incorrect old value provided` retry warning; verify with `git fetch origin && git rev-list --left-right --count origin/main...HEAD`.
|
||||
|
||||
### Secrets — never commit or paste
|
||||
`keystore.properties`, `*.jks`, `.env*`, and `android/` are gitignored. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager.
|
||||
Signing material (`keystore.properties`, `*.jks`) lives **outside the repo** at `~/.config/skatespots/android/`; `*.jks`, `.env*`, and `android/` remain gitignored as a safety net. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager. Do not bind the Vite dev server to the LAN; use `adb reverse` for device testing.
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
|
@ -3321,6 +3530,16 @@ These components follow a consistent pattern using React's `forwardRef` and use
|
|||
|
||||
The project includes a **`skills/`** directory containing specialized implementation guides for specific features. Each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. **Always reference the appropriate SKILL.md file when implementing related functionality.**
|
||||
|
||||
### Deferred: NIP-05 as a wallet address
|
||||
**Do not set `lud16` to `name@skate-spots.org` until an LNURL-pay service exists.**
|
||||
NIP-05 is a Nostr identity record; a Lightning address is a separate
|
||||
`/.well-known/lnurlp/<name>` service. A Minibits-style wallet address needs the
|
||||
NIP-05 server **and** an LNURL/mint/claim service (plus rate limits, backups,
|
||||
privacy copy and a clear mint-trust model). Optional future paths:
|
||||
self-hosted LNURL + Cashu mint, or a deliberate npub.cash/Minibits-style
|
||||
integration. Until then keep NIP-05 as identity and use NWC/built-in Cashu;
|
||||
never overwrite a user's custom `lud16`.
|
||||
|
||||
### Wallet Skills
|
||||
|
||||
- **`skills/bitcoin-wallet/SKILL.md`**: Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns. Read when building Bitcoin wallet functionality, adding on-chain transaction support, implementing UTXO management, or integrating Bitcoin with Nostr-based applications.
|
||||
|
|
|
|||
19
ESCROW.md
|
|
@ -77,11 +77,26 @@ token in a public event is spendable by anyone who sees it.
|
|||
promptly, and lock windows should be generous.
|
||||
- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too.
|
||||
|
||||
## Optional 2-of-3 primitive
|
||||
|
||||
`escrowP2PKOptions` now accepts an optional `arbiterPubkey` and can express the
|
||||
NUT-11 policy used by a future 2-of-3 escrow:
|
||||
|
||||
- normal path: seller + arbiter, `n_sigs=2`;
|
||||
- refund path: buyer + arbiter, `n_sigs_refund=2`;
|
||||
- no arbiter: the current 1-of-1 seller/buyer policy.
|
||||
|
||||
The primitive is unit-tested, but **no UI or default flow uses it yet**. There is
|
||||
no arbiter policy, bond, dispute process or appeal path, so exposing it would add
|
||||
friction and false confidence. Do not enable it for real money until those are
|
||||
defined and the flow is verified on-device.
|
||||
|
||||
## Status
|
||||
|
||||
- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation,
|
||||
`escrowP2PKOptions`, offline token inspection/`parseEscrowToken`,
|
||||
locktime/refund policy, and the injectable `createEscrowToken` mint flow.
|
||||
`escrowP2PKOptions` (1-of-1, optional 2-of-3), offline token
|
||||
inspection/`parseEscrowToken`, locktime/refund policy, and the injectable
|
||||
`createEscrowToken` mint flow.
|
||||
- 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test
|
||||
checklist** below before treating as production. Do not use real money until
|
||||
every box passes.
|
||||
|
|
|
|||
19
ZAPSTORE.md
|
|
@ -156,16 +156,18 @@ Or use Android Studio: right-click `app` → New → Image Asset → import `pub
|
|||
## Step 5 — Generate a signing keystore (one-time)
|
||||
|
||||
```bash
|
||||
install -d -m 700 ~/.config/skatespots/android
|
||||
keytool -genkey -v \
|
||||
-keystore skatespots-release.jks \
|
||||
-keystore ~/.config/skatespots/android/skatespots-release.jks \
|
||||
-alias skatespots \
|
||||
-keyalg RSA \
|
||||
-keysize 2048 \
|
||||
-validity 10000 \
|
||||
-dname "CN=SkateSpots, OU=SkateSpots, O=SkateSpots, L=Bangkok, S=Bangkok, C=TH"
|
||||
chmod 600 ~/.config/skatespots/android/skatespots-release.jks
|
||||
```
|
||||
|
||||
**Save the keystore file and passwords somewhere safe — you need them for every future release.**
|
||||
Put the passwords in `~/.config/skatespots/android/keystore.properties` (mode 600), not in the repo. **Save the keystore file and passwords somewhere safe — you need them for every future release.**
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -175,18 +177,7 @@ keytool -genkey -v \
|
|||
npm run android:build
|
||||
```
|
||||
|
||||
Or manually with signing:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx cap sync android
|
||||
cd android
|
||||
./gradlew assembleRelease \
|
||||
-Pandroid.injected.signing.store.file=../skatespots-release.jks \
|
||||
-Pandroid.injected.signing.store.password=YOUR_STORE_PASSWORD \
|
||||
-Pandroid.injected.signing.key.alias=skatespots \
|
||||
-Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD
|
||||
```
|
||||
`npm run android:build` injects the external signing config automatically. Do **not** pass signing passwords as `-Pandroid.injected...` command-line properties; that exposes them in shell history and process listings.
|
||||
|
||||
APK output: `android/app/build/outputs/apk/release/app-release.apk`
|
||||
|
||||
|
|
|
|||
19
deploy/cordn/.env.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Copy this file to .env, then generate a key:
|
||||
# openssl rand -hex 32
|
||||
# Keep .env mode 600 and back it up with the coordinator data.
|
||||
CORDN_SERVER_PRIVATE_KEY=
|
||||
|
||||
# ContextVM relays the coordinator connects to. Add a second one if you want
|
||||
# more reachability; the coordinator does not need an inbound port.
|
||||
CORDN_RELAY_URLS=wss://relay.contextvm.org,wss://relay2.contextvm.org
|
||||
|
||||
# Publicly announce the coordinator on its relays.
|
||||
CORDN_ANNOUNCED=true
|
||||
CORDN_SERVER_NAME=my-crew-coordinator
|
||||
|
||||
# Per-identity abuse protection. Raise for larger crews; keep it bounded.
|
||||
CORDN_RATE_LIMIT_ENABLED=true
|
||||
CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
|
||||
CORDN_RATE_LIMIT_BURST=80
|
||||
CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
|
||||
CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1
|
||||
153
deploy/cordn/README.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Crew-owned Cordn coordinator
|
||||
|
||||
This kit runs a **Cordn MLS delivery-service coordinator** that a crew owns. It
|
||||
is transport only: the coordinator sees ciphertext and routing metadata (who,
|
||||
when, which group), never message plaintext. It can withhold or delay messages;
|
||||
it cannot forge members' messages.
|
||||
|
||||
This is a **server** kit. SkateSpots remains a Cordn *client*; it does not embed a
|
||||
coordinator in the APK.
|
||||
|
||||
## What you need
|
||||
|
||||
- A small Linux server or always-on home machine with Docker + Compose.
|
||||
- Outbound HTTPS/WSS access. **No inbound port is required** — the coordinator
|
||||
connects out to its ContextVM relay(s).
|
||||
- A domain is **not** required for the coordinator itself.
|
||||
- A crew willing to trust the operator with availability and metadata.
|
||||
|
||||
## 1. Install
|
||||
|
||||
### Easiest: one command
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.skate-spots.org/skatespots/SkateSpots/raw/branch/main/deploy/cordn/install.sh | bash
|
||||
```
|
||||
|
||||
It checks Docker, generates the coordinator key, writes a private `.env`, starts
|
||||
SQLite persistence, waits for the coordinator to announce, and prints the
|
||||
`nprofile`/add link.
|
||||
|
||||
No inbound port, domain or TLS setup is required. The coordinator connects
|
||||
outbound to the ContextVM relays in `CORDN_RELAY_URLS`.
|
||||
|
||||
### Manual: from this directory
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Put the generated value into `CORDN_SERVER_PRIVATE_KEY`. Set
|
||||
`CORDN_RELAY_URLS` to the relay(s) you want; the default is ContextVM’s public
|
||||
relay. Set a useful `CORDN_SERVER_NAME`.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f cordn
|
||||
```
|
||||
|
||||
The server prints its coordinator public identity/add link. It may show an
|
||||
`nprofile` or a `cordn.net/chat/coordinators?c=…` link.
|
||||
|
||||
If your build does not print an add link, derive the public key from the private
|
||||
key using the Cordn CLI or `nostr-tools`, then encode an `nprofile` with the same
|
||||
relay hints. Keep the private key on the server.
|
||||
|
||||
### Can I run it on a phone?
|
||||
|
||||
Not with this Docker kit. A phone can **administer** a coordinator, and Cordn's
|
||||
own phone app is the hand-off path, but an always-on coordinator needs a machine
|
||||
that stays awake and reachable. Android may suspend a foreground coordinator;
|
||||
do not promise crew members that a phone is a reliable coordinator host.
|
||||
|
||||
For a crew that wants ownership without a VPS, an old Raspberry Pi or a
|
||||
small always-on home server is the simplest reliable option. The coordinator
|
||||
still needs no inbound port.
|
||||
|
||||
## 2. Adopt it in SkateSpots
|
||||
|
||||
Either:
|
||||
|
||||
- Settings → **Coordinator** → paste the `nprofile`/add-link → **Add**; or
|
||||
- Crew edit → **Coordinator** → paste it and save; or
|
||||
- scan the coordinator QR.
|
||||
|
||||
Use exactly the relay hints from the server. A mismatch produces an honest
|
||||
“Not seen” health state even when the server is actually running.
|
||||
|
||||
The coordinator should show **Online** (or “Stale” if its heartbeat is old).
|
||||
“Offline” means presence was not found, not necessarily that the service is
|
||||
broken.
|
||||
|
||||
## 3. What the crew should know
|
||||
|
||||
- The coordinator can **censor or drop messages**.
|
||||
- It cannot **read** MLS plaintext.
|
||||
- It can see **who, when and which group**.
|
||||
- It can disappear; local messages remain on members' devices, but delivery
|
||||
pauses until the coordinator returns or the crew switches.
|
||||
- The operator should **not** also become a crew admin automatically.
|
||||
- A loss of the database is a loss of delivery history; back it up.
|
||||
|
||||
## 4. Backup
|
||||
|
||||
```bash
|
||||
./backup.sh
|
||||
```
|
||||
|
||||
Backups land in `./backups/` as a compressed copy of the coordinator volume.
|
||||
Store them off the server, encrypted.
|
||||
|
||||
## 5. Restore
|
||||
|
||||
Stop the coordinator, restore the archive into the `cordn-data` volume, start it
|
||||
again:
|
||||
|
||||
```bash
|
||||
docker compose stop cordn
|
||||
./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
|
||||
docker compose start cordn
|
||||
```
|
||||
|
||||
## 6. Upgrade
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The image is pinned only by the `latest` tag. If you need reproducible upgrades,
|
||||
pin a digest and record it in your crew runbook.
|
||||
|
||||
## 7. Move to a new coordinator
|
||||
|
||||
MLS delivery state is tied to the coordinator’s database and identity. Before
|
||||
switching:
|
||||
|
||||
1. back up the current coordinator;
|
||||
2. export/retain the crew’s local chat state on members’ devices;
|
||||
3. start the new coordinator;
|
||||
4. paste its `nprofile` into the crew record;
|
||||
5. have members reopen the private chat and re-establish delivery;
|
||||
6. update the crew operator/notice.
|
||||
|
||||
Do not claim the switch is seamless unless the old and new coordinator state
|
||||
have actually been migrated.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Never commit `.env`; it contains the coordinator signing key.
|
||||
- The coordinator key is **not** a crew admin key and must not be reused as one.
|
||||
- Run it behind a trusted host/network; outbound relay traffic is metadata.
|
||||
- Keep Docker and the image current.
|
||||
- Do not expose an OpenCode or coordinator admin port publicly.
|
||||
- This is **not** a BFT or multi-coordinator system. One coordinator is a
|
||||
delivery service under the crew's social trust.
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml` — persistent SQLite deployment.
|
||||
- `.env.example` — required environment.
|
||||
- `backup.sh` / `restore.sh` — volume snapshots.
|
||||
14
deploy/cordn/backup.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh
|
||||
# Snapshot the Cordn coordinator data volume. Run from this directory.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
mkdir -p backups
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
out="cordn-$stamp.tgz"
|
||||
docker run --rm \
|
||||
-v cordn_cordn-data:/data:ro \
|
||||
-v "$PWD/backups":/backup \
|
||||
alpine:3.20 \
|
||||
sh -c "tar czf /backup/$out -C /data ."
|
||||
echo "Backup: $PWD/backups/$out"
|
||||
echo "Store it off this server and keep it encrypted."
|
||||
19
deploy/cordn/docker-compose.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
name: cordn
|
||||
|
||||
services:
|
||||
cordn:
|
||||
image: ghcr.io/cordn-msg/cordn:latest
|
||||
container_name: cordn
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
CORDN_STORAGE_BACKEND: sqlite
|
||||
CORDN_SQLITE_PATH: /data/cordn.sqlite
|
||||
volumes:
|
||||
- cordn-data:/data
|
||||
# No inbound port is required: the coordinator connects *out* to the
|
||||
# ContextVM relay(s) configured in CORDN_RELAY_URLS.
|
||||
|
||||
volumes:
|
||||
cordn-data:
|
||||
77
deploy/cordn/install.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-command crew coordinator installer.
|
||||
#
|
||||
# curl -fsSL https://git.skate-spots.org/skatespots/SkateSpots/raw/branch/main/deploy/cordn/install.sh | bash
|
||||
#
|
||||
# Creates a persistent Cordn coordinator with SQLite storage, starts it, and
|
||||
# prints the nprofile/add link when the server announces itself. The coordinator
|
||||
# connects OUT to ContextVM relays; it needs no inbound port or TLS domain.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")" 2>/dev/null || true
|
||||
umask 077
|
||||
|
||||
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
|
||||
need docker
|
||||
need openssl
|
||||
docker compose version >/dev/null 2>&1 || { echo "Docker Compose v2 is required (docker compose)." >&2; exit 1; }
|
||||
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
cat > docker-compose.yml <<'YAML'
|
||||
name: cordn
|
||||
services:
|
||||
cordn:
|
||||
image: ghcr.io/cordn-msg/cordn:latest
|
||||
container_name: cordn
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
CORDN_STORAGE_BACKEND: sqlite
|
||||
CORDN_SQLITE_PATH: /data/cordn.sqlite
|
||||
volumes:
|
||||
- cordn-data:/data
|
||||
volumes:
|
||||
cordn-data:
|
||||
YAML
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
NAME="${CORDN_SERVER_NAME:-skatespots-crew}"
|
||||
RELAYS="${CORDN_RELAY_URLS:-wss://relay.contextvm.org,wss://relay2.contextvm.org}"
|
||||
KEY="$(openssl rand -hex 32)"
|
||||
cat > .env <<EOF
|
||||
CORDN_SERVER_PRIVATE_KEY=$KEY
|
||||
CORDN_RELAY_URLS=$RELAYS
|
||||
CORDN_ANNOUNCED=true
|
||||
CORDN_SERVER_NAME=$NAME
|
||||
CORDN_STORAGE_BACKEND=sqlite
|
||||
CORDN_SQLITE_PATH=/data/cordn.sqlite
|
||||
CORDN_RATE_LIMIT_ENABLED=true
|
||||
CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
|
||||
CORDN_RATE_LIMIT_BURST=80
|
||||
CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
|
||||
CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1
|
||||
EOF
|
||||
chmod 600 .env
|
||||
echo "Generated .env (keep it private; it contains the coordinator key)."
|
||||
fi
|
||||
|
||||
docker compose up -d
|
||||
echo "Waiting for the coordinator to announce itself…"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
logs="$(docker compose logs --no-color 2>&1 || true)"
|
||||
if printf '%s\n' "$logs" | grep -q 'nprofile'; then
|
||||
printf '\n%s\n' "$logs" | grep -A12 -B2 'CORDN COORDINATOR'
|
||||
echo
|
||||
echo "Back up .env and the cordn-data volume before inviting the crew."
|
||||
echo "Adopt it in SkateSpots: Settings → Coordinator, or Crew edit → Coordinator."
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "The container started but no announcement appeared within 60s." >&2
|
||||
echo "Inspect: docker compose logs --no-color" >&2
|
||||
exit 1
|
||||
17
deploy/cordn/restore.sh
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
# Restore a Cordn coordinator backup into the cordn-data volume.
|
||||
# Usage: ./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
[ "$#" -eq 1 ] || { echo "usage: $0 backups/cordn-YYYYMMDD-HHMMSS.tgz" >&2; exit 1; }
|
||||
archive="$1"
|
||||
[ -f "$archive" ] || { echo "no such backup: $archive" >&2; exit 1; }
|
||||
printf 'This replaces the current coordinator volume. Type "restore" to continue: '
|
||||
read -r answer
|
||||
[ "$answer" = restore ] || { echo "aborted" >&2; exit 1; }
|
||||
docker run --rm \
|
||||
-v cordn_cordn-data:/data \
|
||||
-v "$(cd "$(dirname "$archive")" && pwd)":/backup:ro \
|
||||
alpine:3.20 \
|
||||
sh -c "rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true; tar xzf /backup/$(basename "$archive") -C /data"
|
||||
echo "Restored $(basename "$archive")."
|
||||
97
distribution/README.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Distribution without GitHub
|
||||
|
||||
SkateSpots is published from a self-hosted **Forgejo** instance. No GitHub
|
||||
account is required.
|
||||
|
||||
## Current release source
|
||||
|
||||
- Forge: <https://git.skate-spots.org/skatespots/SkateSpots>
|
||||
- Latest release: <https://git.skate-spots.org/skatespots/SkateSpots/releases/latest>
|
||||
- Signed APK (1.7.83):
|
||||
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.83/SkateSpots-1.7.83.apk>
|
||||
- SHA-256:
|
||||
`1fe70588adea237045b117ccdf013811b87a8368caf6b1148a7ab7da0a6250c3`
|
||||
|
||||
The APK is signed with the same key as the Zapstore build. Installing an
|
||||
F-Droid-signed build over it would require uninstalling SkateSpots and losing
|
||||
the local key/settings. Obtanium and direct APK updates keep the install.
|
||||
|
||||
## Obtanium
|
||||
|
||||
Obtanium cannot use a Zapstore URL as a source. Use the self-hosted Forgejo
|
||||
release page instead:
|
||||
|
||||
1. Open Obtanium → **Add App**.
|
||||
2. Paste:
|
||||
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`
|
||||
3. If Obtanium does not recognise the self-hosted host, select **Override
|
||||
Source → HTML**.
|
||||
4. Set version extraction if desired:
|
||||
`SkateSpots-([0-9.]+)\.apk`, match group `1`.
|
||||
|
||||
The release page contains exactly one `.apk`, so Obtanium can follow the latest
|
||||
release without a custom store listing.
|
||||
|
||||
## F-Droid family
|
||||
|
||||
### Self-hosted F-Droid repo (live now)
|
||||
|
||||
A signed third-party F-Droid repository is published here:
|
||||
|
||||
- Repo: <https://fdroid.skate-spots.org/repo>
|
||||
- App ID: `org.skatespots.app`
|
||||
- Repo signing fingerprint:
|
||||
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`
|
||||
|
||||
Users can add this URL in F-Droid, or add it in Obtanium as an **F-Droid Third
|
||||
Party Repo**. This is not F-Droid main: it is a self-hosted, signed repository
|
||||
of the developer-signed APK. It keeps the existing install and signing key.
|
||||
|
||||
The repository metadata and APK are served from the relay host. The repo index
|
||||
is signed with a repository key stored on that host; the APK itself keeps the
|
||||
SkateSpots developer signature.
|
||||
|
||||
### No external store identities
|
||||
|
||||
**Owner decision:** distribution stays self-hosted and Nostr-based. No GitLab,
|
||||
Codeberg, GitHub, email or F-Droid-main accounts are created or requested.
|
||||
|
||||
F-Droid main and IzzyOnDroid both require external identities and review, so
|
||||
they are intentionally out of scope. The self-hosted repo above is the supported
|
||||
F-Droid-family channel.
|
||||
|
||||
`distribution/fdroiddata/` and `fastlane/` are kept as an unused technical
|
||||
reference only. A fresh local clone at `5ebf741` built the unsigned APK through
|
||||
the recipe's `npm ci → android:init → android:sync → assembleRelease` path, but
|
||||
the recipe must not be submitted.
|
||||
|
||||
## Release checklist
|
||||
|
||||
1. `npm test`
|
||||
2. `scripts/release.sh patch --yes --deploy` (Zapstore + web)
|
||||
3. Push the release tag and `main` to Forgejo.
|
||||
4. Upload the APK through the helper:
|
||||
`FORGEJO_TOKEN=... scripts/publish-forgejo.sh <version>`
|
||||
(or set `FORGEJO_TOKEN` before `release.sh`; the APK upload is then automatic)
|
||||
5. Copy the signed APK into the F-Droid repo on the relay host, add a
|
||||
`metadata/org.skatespots.app.yml` changelog entry, run `fdroid update`, and
|
||||
sync `repo/` to `/var/www/fdroid/repo/`:
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/fdroid
|
||||
cp /path/to/SkateSpots-<version>.apk repo/
|
||||
. /home/ubuntu/fdroid-venv/bin/activate
|
||||
fdroid update
|
||||
sudo rsync -a --delete repo/ /var/www/fdroid/repo/
|
||||
sudo chown -R caddy:caddy /var/www/fdroid
|
||||
```
|
||||
|
||||
6. Verify the public latest-release page contains the new `.apk`, and that
|
||||
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
|
||||
`versionCode`.
|
||||
7. Update the static download page if the displayed version/checksum changes:
|
||||
`public/download/index.html`.
|
||||
|
||||
The Forgejo instance runs on the same host as the relay. It exposes public
|
||||
read access and HTTPS through Caddy; registration is disabled and administrative
|
||||
credentials are kept on the host, not in this repository.
|
||||
51
distribution/fdroiddata/org.skatespots.app.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
Categories:
|
||||
- Sports & Health
|
||||
License: AGPL-3.0-only
|
||||
AuthorName: SkateSpots
|
||||
WebSite: https://skate-spots.org
|
||||
SourceCode: https://git.skate-spots.org/skatespots/SkateSpots
|
||||
IssueTracker: https://git.skate-spots.org/skatespots/SkateSpots/issues
|
||||
Changelog: https://git.skate-spots.org/skatespots/SkateSpots/releases
|
||||
|
||||
AutoName: SkateSpots
|
||||
Summary: Local-first skate map, crews, events and marketplace
|
||||
Description: |-
|
||||
SkateSpots is a local-first skating map for finding spots, crews, events and
|
||||
gear. Your keys stay on your device, your messages stay in encrypted crew
|
||||
chats and the app does not use analytics or sell your data.
|
||||
|
||||
Find skate spots on the map, add new ones, organise sessions with your crew,
|
||||
follow events and browse the community marketplace. The Android app bundles
|
||||
the same web app for fast shell startup and native camera, location, push and
|
||||
NFC support where available.
|
||||
|
||||
SkateSpots is self-hosted and built on open protocols. You can run your own
|
||||
relay and Blossom storage instead of relying on a central service.
|
||||
|
||||
RepoType: git
|
||||
Repo: https://git.skate-spots.org/skatespots/SkateSpots.git
|
||||
|
||||
Builds:
|
||||
- versionName: 1.7.61
|
||||
versionCode: 10761
|
||||
commit: 5ebf741427303be0b0feeff5ce1d8420e2c0ddea
|
||||
subdir: .
|
||||
prebuild:
|
||||
- npm ci
|
||||
- npm run android:init
|
||||
- npm run android:sync
|
||||
# F-Droid signs the APK itself. Remove the developer signing config so
|
||||
# Gradle emits an unsigned release APK instead of requiring the keystore.
|
||||
- sed -i '/signingConfig signingConfigs.release/d' android/app/build.gradle
|
||||
build:
|
||||
- cd android && ./gradlew assembleRelease --no-daemon
|
||||
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
|
||||
# The generated Android project is intentionally not committed. This
|
||||
# recipe recreates it with Capacitor before Gradle runs. F-Droid will sign
|
||||
# the resulting APK with its own key. The F-Droid buildserver supplies the
|
||||
# JDK/Android SDK; do not install packages with sudo in a recipe.
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 1.7.61
|
||||
CurrentVersionCode: 10761
|
||||
104
docs/THREAT_MODEL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# SkateSpots threat model
|
||||
|
||||
This is the short, technical version of what SkateSpots can and cannot guarantee.
|
||||
It exists so product copy, support answers and future code do not accidentally
|
||||
promise consensus, arbitration or delivery verification that the system does not
|
||||
have.
|
||||
|
||||
## The model in one sentence
|
||||
|
||||
**SkateSpots verifies signed facts, keeps a local view, and uses social/economic
|
||||
signals at value boundaries. It does not run a consensus protocol.**
|
||||
|
||||
Nostr relays are not voters. A relay can withhold, censor, reorder or serve a
|
||||
stale event, but it cannot forge a valid signature. Different clients can
|
||||
therefore see different snapshots of the network without either side being
|
||||
"wrong" in a Byzantine-consensus sense.
|
||||
|
||||
## Assets
|
||||
|
||||
- Nostr identity keys and local signer access.
|
||||
- Local cache, drafts, pending posts and local deletion tombstones.
|
||||
- Crew membership and Cordn group membership.
|
||||
- Marketplace listings, offers, trade attestations and reputation.
|
||||
- Cashu proofs and escrow tokens.
|
||||
- Device storage and any local backup/export.
|
||||
|
||||
## Actors and trust boundaries
|
||||
|
||||
| Actor | Can do | Cannot do |
|
||||
| --- | --- | --- |
|
||||
| Author | Sign events with their key | Forge another author's signature |
|
||||
| Relay | Withhold, censor, reorder, serve stale or conflicting signed events | Forge a valid event; guarantee availability |
|
||||
| Cordn coordinator | Route opaque group ciphertext; drop or delay messages | Read plaintext; forge an author proof |
|
||||
| Cashu mint | Issue/redeem proofs, enforce locktime, freeze or become insolvent | Spend a correctly locked P2PK proof without a valid witness |
|
||||
| Founder/admin | Modify the crew record, moderate crew content | Forge members' signatures; guarantee global agreement |
|
||||
| Buyer/seller | Publish attestations and payment/receipt claims | Make a false claim true by signing it |
|
||||
| WebView / host | Run the client | Be assumed trustworthy after device compromise |
|
||||
|
||||
## Guarantees
|
||||
|
||||
- **Authenticity:** a valid Schnorr signature proves which key signed an event,
|
||||
not that the content is true.
|
||||
- **Availability:** the client can use cached data and multiple relays; there is
|
||||
no guarantee that every relay has every event.
|
||||
- **Local determinism:** when two valid versions of an addressable or replaceable
|
||||
event exist, the client chooses the newest `created_at`, with an event-id
|
||||
tie-breaker. This is deterministic, not globally agreed.
|
||||
- **Deletion:** NIP-09 is a request. Local tombstones prevent a deleted item from
|
||||
flashing back on that device; relays may ignore the request.
|
||||
- **Payments:** a Lightning preimage proves the invoice was paid. It does not
|
||||
prove goods were delivered.
|
||||
- **Escrow:** a time-locked Cashu P2PK token protects the buyer after the lock
|
||||
expires and lets the seller redeem on delivery. It is not arbitration and does
|
||||
not prove delivery.
|
||||
- **Reputation:** reciprocal signed attestations, distinct partners and
|
||||
web-of-trust weighting are evidence. Two colluding accounts can still create a
|
||||
fake trade; the app must not present that as proof of goods.
|
||||
|
||||
## Non-guarantees
|
||||
|
||||
- No global ordering or consensus over relays.
|
||||
- No protection from a compromised identity key, signer or device.
|
||||
- No guaranteed delivery or carrier verification.
|
||||
- No neutral dispute resolution.
|
||||
- No guarantee that every relay honours deletion or moderation.
|
||||
- No guarantee that a mint remains solvent, online or P2PK-compatible.
|
||||
- No anonymity guarantee: public events, relays, image hosts and map/weather
|
||||
providers may observe network metadata.
|
||||
- No guarantee that a Cordn coordinator cannot censor a group. It cannot read or
|
||||
forge messages, but it can drop them.
|
||||
|
||||
## Cordn specifically
|
||||
|
||||
The current Cordn group scheme is NIP-44 group delivery with a signed author
|
||||
proof inside the ciphertext. It is not full MLS yet: it does not provide MLS
|
||||
forward secrecy or post-compromise security. A coordinator cannot read the
|
||||
message or impersonate the author, but it can refuse to relay it.
|
||||
|
||||
## Marketplace safety rules
|
||||
|
||||
- Prefer escrowed or in-person deals for high-value trades.
|
||||
- Never send a plain, unlocked Cashu token in a public event.
|
||||
- Treat "confirmed trade" as "both parties signed", not "goods verified".
|
||||
- Treat "payment verified" as "the money leg is verified".
|
||||
- A new account with no distinct confirmed partners is a warning, not a ban.
|
||||
- Cash, barter and off-platform settlement have no on-chain proof; use
|
||||
reputation and judgement.
|
||||
|
||||
## Privacy boundaries
|
||||
|
||||
- Public Nostr events are public forever in practice; deletion is best-effort.
|
||||
- DMs and Cordn ciphertext are encrypted, but metadata (who, when, which group)
|
||||
may be visible to relays/coordinators depending on the feature.
|
||||
- Images are public once uploaded; strip metadata before upload and do not put
|
||||
addresses in public listing images.
|
||||
- Self-hosted infrastructure avoids third-party analytics, but network observers
|
||||
can still see traffic to relays, image hosts, map tiles and weather services.
|
||||
|
||||
## What would change this model
|
||||
|
||||
A real consensus or arbitration layer would need a separate design with explicit
|
||||
participants, quorum, liveness, slashing/appeals and privacy trade-offs. It
|
||||
should not be smuggled in as "trust" copy or a global score. Until then:
|
||||
**signed facts, local merge, explicit boundaries, no fake guarantees.**
|
||||
1
fastlane/metadata/android/en-US/changelogs/10761.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Cordn setup now shows visible progress: the device-key step is checked once prepared, and the approval step shows a spinner while waiting. The APK uses the same signing key as the Zapstore release.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10762.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Small correctness pass: 404 navigation stays in-app, profile badge URLs are sanitized, marketplace readiness only counts a usable Lightning address, and the duplicate Korean picker entry is hidden.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10763.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Deterministic relay merging with a stable event-id tie-break and local deletion tombstones; EXIF/GPS stripping on upload; Android cloud backup disabled; local OpenSats icon; added the trust-model document.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10764.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Listings from a new seller with no confirmed trading partner now show a compact safety note beside the trade actions: meet in public, prefer escrow for shipped deals, and never pay outside the app's rails.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10765.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Settings now has an on-demand relay consistency check for profile snapshots; it reports differences without merging data. Trade reputation also shows how many mutually-confirmed trades happened in the last 90 days, alongside lifetime counts.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10766.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
High-value listings now show the safety note including the no-arbitration reminder; reputation uses a 180-day half-life and recent 90-day count; the 2-of-3 escrow policy primitive is unit-tested but not exposed until an arbiter policy exists.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10767.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Added a production crew-owned Cordn coordinator kit: Docker Compose with SQLite persistence, an env template, backup/restore scripts, and a runbook. The coordinator needs only outbound relay access; Settings and crew edit link to the kit. The app still does not embed a coordinator.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10769.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The desktop header status control is now a compact chip showing your current status emoji and label, or a neutral Status affordance when none is set. No permanent ping or large outlined pill.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10770.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Amber users now unlock encrypted conversations explicitly instead of triggering a decrypt storm on Messages. Amber redirect requests expire after 10 minutes, and plaintext/crypto results are no longer persisted in browser storage.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10771.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The desktop status control is now a compact emoji-only round chip with a tooltip, so it cannot collide with profile controls. Removed the duplicate online dot from the mobile avatar.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10772.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Relay events are now verified before the shared content merge, and images/avatars use a no-referrer policy to reduce third-party metadata leaks.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10773.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The login dialog now shows two adaptive methods: Amber on Android, or NIP-46 QR pairing on desktop. Bunker paste is a secondary path, and the duplicate NIP-46 card was removed.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10774.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Added a one-command crew coordinator installer that generates the key, starts persistent SQLite storage, and prints the coordinator link. Settings now shows the installer command; no inbound port is required.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10775.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Crew-owned coordinators are now authoritative for their crews, join-request notifications poll each coordinator independently, and device-link failures show an inline retry instead of a stuck loading dialog.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10776.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Added proper 192px and 512px PWA icons plus a 512px maskable icon and a 180px Apple touch icon. The signup recovery-code print popup now respects the dynamic mobile viewport.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10777.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Simplified wallet setup: removed the brand directory and duplicate Add Wallet action, hide WebLN unless detected, and added one neutral connection path.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10778.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The native Wallet page and wallet manager now lead with the built-in SkateSpots ecash wallet, with an existing Lightning wallet as the secondary path. Bottom navigation labels are slightly larger.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10779.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Raw nsec logins are now session-only and removed from persistent browser storage. Direct links to spots, crews, events and listings now enter the app router correctly, including cold native App Links.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10780.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Marketplace UX overhaul: URL-synced filters, mobile filter/action sheets, real link cards, normalized price sorting, draft autosave, live preview and photo cover controls. Show More adds listings beyond the first 200.
|
||||
1
fastlane/metadata/android/en-US/changelogs/10781.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Grantmaker inquiry: shareable grants tab, progressive form, review/copy fallback, website validation and clearer funding priorities. Community invite uses the canonical /community link; retired Nostr relay removed from active configs.
|
||||
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
SkateSpots is a local-first skating map for finding spots, crews, events and
|
||||
gear. Your keys stay on your device, your messages stay in encrypted crew chats
|
||||
and the app does not use analytics or sell your data.
|
||||
|
||||
Find skate spots on the map, add new ones, organise sessions with your crew,
|
||||
follow events and browse the community marketplace. The Android app bundles the
|
||||
same web app for fast offline shell startup and native camera, location, push
|
||||
and NFC support where available.
|
||||
|
||||
SkateSpots is self-hosted and built on open protocols. You can run your own
|
||||
relay and Blossom storage instead of relying on a central service.
|
||||
|
||||
Lightning payments are optional and self-custodial. Transport and driver
|
||||
profiles are self-attested; SkateSpots does not verify a licence, insurance or
|
||||
vehicle roadworthiness.
|
||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Local-first skate map, crews, events and marketplace.
|
||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
SkateSpots
|
||||
35
index.html
|
|
@ -3,7 +3,8 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>SkateSpots - Decentralized Skate Map & Community</title>
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<title>SkateSpots — Open-sourced Skate Community App</title>
|
||||
|
||||
<!--
|
||||
Content-Security-Policy (defense-in-depth) — shipped in the HTML so it
|
||||
|
|
@ -44,11 +45,11 @@
|
|||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" type="image/png" href="/images/icon-1024.png">
|
||||
<link rel="apple-touch-icon" href="/images/icon-1024.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/images/icon-1024.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/icon-1024.png">
|
||||
<link rel="apple-touch-startup-image" href="/images/icon-1024.png">
|
||||
<link rel="icon" type="image/png" href="/images/brand-192.png">
|
||||
<link rel="apple-touch-icon" href="/images/brand-180.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/brand-180.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/brand-180.png">
|
||||
<link rel="apple-touch-startup-image" href="/images/brand-1024.png">
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#ff0040">
|
||||
|
|
@ -57,21 +58,27 @@
|
|||
<meta name="apple-mobile-web-app-title" content="SkateSpots">
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr. Free, decentralized, censorship-resistant.">
|
||||
<meta name="keywords" content="skateboarding, skate spots, skate parks, nostr, decentralized, bitcoin, lightning, zaps">
|
||||
<meta name="description" content="An open-sourced skate community app for map, crews, events, gear and more.">
|
||||
<meta name="keywords" content="skateboarding, skate spots, skate parks, nostr, open source, bitcoin, lightning, zaps">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="SkateSpots - Decentralized Skate Map & Community">
|
||||
<meta property="og:description" content="Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr.">
|
||||
<meta property="og:image" content="https://skate-spots.org/images/icon-1024.png">
|
||||
<meta property="og:site_name" content="SkateSpots">
|
||||
<meta property="og:title" content="SkateSpots — Open-sourced Skate Community App">
|
||||
<meta property="og:description" content="Map, crews, events, gear and more — an open-sourced skate community app.">
|
||||
<meta property="og:image" content="https://skate-spots.org/images/og-card-v2.png">
|
||||
<meta property="og:image:type" content="image/png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="SkateSpots — open-sourced skate community app for map, crews, events and gear">
|
||||
<meta property="og:url" content="https://skate-spots.org">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SkateSpots - Decentralized Skate Map">
|
||||
<meta name="twitter:description" content="Discover skate spots, find coaches, join events on Nostr.">
|
||||
<meta name="twitter:image" content="https://skate-spots.org/images/icon-1024.png">
|
||||
<meta name="twitter:title" content="SkateSpots — Open-sourced Skate Community App">
|
||||
<meta name="twitter:description" content="Map, crews, events and gear for the skate community.">
|
||||
<meta name="twitter:image" content="https://skate-spots.org/images/og-card-v2.png">
|
||||
<meta name="twitter:image:alt" content="SkateSpots — open-sourced skate community app for map, crews, events and gear">
|
||||
|
||||
<!-- Leaflet CSS is self-hosted (imported in main.tsx) — no unpkg/CDN. -->
|
||||
<!-- Warm up the connections used on almost every visit: our relay + media. -->
|
||||
|
|
|
|||
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "skate-spots",
|
||||
"version": "1.7.57",
|
||||
"version": "1.7.83",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "skate-spots",
|
||||
"version": "1.7.57",
|
||||
"version": "1.7.83",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "skate-spots",
|
||||
"private": true,
|
||||
"version": "1.7.57",
|
||||
"version": "1.7.83",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"homepage": "https://skate-spots.org",
|
||||
|
|
@ -21,11 +21,11 @@
|
|||
"check:i18n": "node scripts/check-i18n.mjs",
|
||||
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
||||
"android:open": "npx cap open android",
|
||||
"android:version": "node scripts/sync-android-version.mjs",
|
||||
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease",
|
||||
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease",
|
||||
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
|
||||
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
||||
"translation:matrix": "node scripts/translation-matrix.mjs"
|
||||
|
|
|
|||
127
public/download/index.html
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>SkateSpots APK</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Download the signed SkateSpots Android APK. No account, no store, no tracking."
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #090b0c;
|
||||
--panel: #121719;
|
||||
--line: #273033;
|
||||
--text: #f2f5f4;
|
||||
--muted: #9aa7a5;
|
||||
--teal: #56e0c0;
|
||||
--amber: #f5b83d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 0%, rgba(86, 224, 192, 0.12), transparent 42%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(100%, 560px);
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--teal);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 { margin: 0; font-size: clamp(28px, 7vw, 38px); line-height: 1.02; }
|
||||
p { margin: 14px 0 0; color: var(--muted); }
|
||||
a.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--teal);
|
||||
color: #06201b;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.download:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
|
||||
a.download span:last-child { font-size: 20px; }
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.links { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 16px; }
|
||||
.links a { color: var(--amber); font-weight: 700; text-decoration: none; }
|
||||
.links a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">SkateSpots · Android</p>
|
||||
<h1>Get the signed APK.</h1>
|
||||
<p>
|
||||
Local-first skating map, crews, events and marketplace. No store account
|
||||
required. This is the same signing key as the Zapstore and Obtanium
|
||||
releases, so it updates over an existing install.
|
||||
</p>
|
||||
|
||||
<a
|
||||
class="download"
|
||||
href="https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.83/SkateSpots-1.7.83.apk"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>Download SkateSpots-1.7.83.apk · 60 MB</span>
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
|
||||
<div class="meta">
|
||||
<span>Requires Android 7.0 or newer.</span>
|
||||
<span>SHA-256: <code>7c4f8e7a50428c97caba41b311df29a0e619f88a0ef00c686a021fbdc8f75ec0</code></span>
|
||||
<span>Only install APKs from a source you trust. Android will ask you to allow installs from your browser.</span>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="https://git.skate-spots.org/skatespots/SkateSpots/releases">All releases</a>
|
||||
<a href="https://git.skate-spots.org/skatespots/SkateSpots">Source code</a>
|
||||
<a href="https://skate-spots.org">Open the web app</a>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span><strong>Obtanium:</strong> add the latest release page above as the app source, or select <em>Override Source → HTML</em> if it is not recognised.</span>
|
||||
<span><strong>F-Droid repo:</strong> <a href="https://fdroid.skate-spots.org/repo">https://fdroid.skate-spots.org/repo</a> (third-party self-hosted repo; app ID <code>org.skatespots.app</code>).</span>
|
||||
<span>Repo signing fingerprint: <code>88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75</code></span>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/images/brand-1024.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
public/images/brand-180.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/brand-192.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/brand-512.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/images/brand-maskable-1024.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
public/images/brand-maskable-512.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 60 KiB |
BIN
public/images/icon-180.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/images/icon-192.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/images/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 60 KiB |
BIN
public/images/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/og-card-v2.png
Normal file
|
After Width: | Height: | Size: 597 KiB |
BIN
public/images/og-card.png
Normal file
|
After Width: | Height: | Size: 597 KiB |
BIN
public/images/opensats.ico
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"name": "SkateSpots - Decentralized Skate Map",
|
||||
"name": "SkateSpots — Open-sourced Skate Community App",
|
||||
"short_name": "SkateSpots",
|
||||
"id": "/",
|
||||
"description": "Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr. Free, decentralized, censorship-resistant.",
|
||||
"description": "Map, crews, events, gear and more — an open-sourced skate community app.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
|
@ -18,20 +18,26 @@
|
|||
"related_applications": [],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/icon-1024.png",
|
||||
"sizes": "180x180",
|
||||
"src": "/images/brand-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/icon-maskable-1024.png",
|
||||
"sizes": "180x180",
|
||||
"src": "/images/brand-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/images/brand-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/images/icon-1024.png",
|
||||
"sizes": "180x180",
|
||||
"src": "/images/brand-1024.png",
|
||||
"sizes": "1024x1024",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
|
|
@ -60,7 +66,7 @@
|
|||
"url": "/map",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/icon-1024.png",
|
||||
"src": "/images/brand-180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
|
@ -73,7 +79,7 @@
|
|||
"url": "/events",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/icon-1024.png",
|
||||
"src": "/images/brand-180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
|
@ -86,7 +92,7 @@
|
|||
"url": "/coaches",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/icon-1024.png",
|
||||
"src": "/images/brand-180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
|
@ -99,7 +105,7 @@
|
|||
"url": "/wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/icon-1024.png",
|
||||
"src": "/images/brand-180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,311 @@
|
|||
🛹 OpenStreetMap tiles load again
|
||||
- OSM was refusing the app because tile requests sent no Referer. The app now sends only the site origin (strict-origin-when-cross-origin), which satisfies OSM's tile policy without exposing page paths.
|
||||
- A fresh osm-v3 tile cache means previously cached "Access blocked" tiles are never reused.
|
||||
- OpenStreetMap remains the basemap; no CARTO or other provider was added.
|
||||
|
||||
---
|
||||
|
||||
🛹 Orange wheel icon restored
|
||||
- The app header, mobile header, splash screen and footer now use the orange SkateSpots wheel again.
|
||||
- The same orange artwork is used for the PWA icons, Apple touch icon, app install metadata and Zapstore listing.
|
||||
- Refreshed the social preview card with the open-sourced Skate Community App wording.
|
||||
|
||||
---
|
||||
|
||||
🛹 OpenStreetMap tiles kept, cache fixed
|
||||
- OpenStreetMap remains the basemap.
|
||||
- Bumped the tile-cache namespace so previously cached OSM "Access blocked" tiles can never be reused.
|
||||
- The map still depends on OSM's public tile service; if OSM blocks the current network/VPN range, the tiles cannot load until the network changes.
|
||||
|
||||
---
|
||||
|
||||
🛹 Grantmaker inquiry, reworked
|
||||
- The Bitcoin Grantmakers tab is now directly shareable at /supporters?tab=grants.
|
||||
- The form is progressive: organization, contact and confirmation first; optional grant details are collapsed.
|
||||
- Added website validation, clearer contact guidance, required-state messaging and a review dialog before the private DM opens.
|
||||
- You can now copy the exact grant message as a fallback, with a clearer success/failure state.
|
||||
- Added concrete examples of what a grant can fund, better contrast and larger touch targets.
|
||||
- Shortened the mobile tab to Grants and softened the grantmaker copy.
|
||||
|
||||
---
|
||||
|
||||
🛹 Community and relay hygiene
|
||||
- The community room invite now uses the canonical /community link and its generated QR.
|
||||
- Removed the retired goskatespots.nostr1.com relay from active CVM, repository and NIP-65 relay lists. It remains only in the blocklist that prunes old client configs.
|
||||
|
||||
---
|
||||
|
||||
🛹 Marketplace UX overhaul
|
||||
- Search, category, listing type, fulfilment, trust and sorting now live in the URL, so refresh, Back/Forward, shared links and saved searches keep their context.
|
||||
- Mobile browsing gets a proper filter sheet and removable active-filter chips instead of a wall of category buttons.
|
||||
- Price sorting now normalizes BTC, sats and fiat to the same value before comparing; listings without an available rate sort last.
|
||||
- Marketplace cards are real links again: keyboard activation, open-in-new-tab, middle-click and the browser context menu all work.
|
||||
- Listing pages get a mobile action sheet for buying, contacting, offers, escrow and reporting, plus a sticky price/action bar.
|
||||
- Contact and share links are canonical and safe to share from the Android app.
|
||||
- Create-listing forms now autosave a local draft, restore it after refresh or accidental navigation, and offer a discard action.
|
||||
- Added a live listing preview before publishing, plus photo cover selection and left/right reordering.
|
||||
- The marketplace now supports Show More instead of silently stopping at the first 200 listings.
|
||||
- Unknown category links now fall back safely instead of producing an empty filter state.
|
||||
|
||||
---
|
||||
|
||||
🔐 Nsec is no longer stored persistently
|
||||
- Raw nsec logins now live only in sessionStorage. Reloading the tab keeps the
|
||||
session; closing the browser/app requires re-entering the key.
|
||||
- Existing raw nsec entries are migrated out of localStorage on the next write.
|
||||
- The login warning now says this explicitly and points to Amber/bunker for
|
||||
staying signed in. Added a regression test that asserts no `nsec1` value lands
|
||||
in persistent localStorage.
|
||||
- Direct links to `/spot`, `/crew`, `/event`, `/listing`, `/card` and similar
|
||||
paths now rewrite into the HashRouter before React mounts, including cold
|
||||
native App Links.
|
||||
|
||||
---
|
||||
|
||||
👛 Built-in wallet first on mobile
|
||||
- On the native app, the Wallet page and wallet manager now lead with the
|
||||
built-in SkateSpots ecash wallet. Connecting an existing Lightning wallet is
|
||||
the secondary path.
|
||||
- The web build keeps the connected-wallet card first because Cashu minting is
|
||||
native-only.
|
||||
- Slightly larger bottom-nav labels for readability on phones.
|
||||
|
||||
---
|
||||
|
||||
👛 A simpler wallet setup
|
||||
- Removed the four-wallet recommendation list from the primary wallet flow.
|
||||
- Removed the “Browser Extension · Not Found” row; WebLN only appears when a
|
||||
provider is actually detected.
|
||||
- Removed the duplicate “Add Wallet” action when no wallet is connected. There
|
||||
is now one clear “Connect Wallet” path.
|
||||
- Added one neutral help line and a generic “how wallets work” link instead of
|
||||
sending users through a brand directory.
|
||||
|
||||
---
|
||||
|
||||
📱 Phone/PWA install polish
|
||||
- Added proper 192px and 512px app icons plus a 512px maskable icon, and a real
|
||||
180px Apple touch icon. The manifest no longer advertises 180px images as
|
||||
1024px.
|
||||
- Fixed the signup recovery-code print popup to use the dynamic viewport height,
|
||||
so it no longer overflows behind mobile browser chrome.
|
||||
|
||||
---
|
||||
|
||||
🧵 Crew coordinator routing is now correct
|
||||
- A crew’s own coordinator is authoritative. If its pointer is incomplete
|
||||
(custom relay without a service pubkey), SkateSpots no longer silently falls
|
||||
back to the SkateSpots coordinator.
|
||||
- Join-request notifications now poll each crew’s coordinator independently, so
|
||||
crews running their own coordinator get request notifications too. One
|
||||
unreachable custom coordinator no longer stops notifications for other crews.
|
||||
- Device-link failures now show an inline error with a Retry button instead of a
|
||||
permanently spinning dialog.
|
||||
|
||||
---
|
||||
|
||||
🏠 One command to run a crew coordinator
|
||||
- Added `deploy/cordn/install.sh`: it generates the coordinator key, writes a
|
||||
private `.env`, starts persistent SQLite storage, and prints the nprofile/add
|
||||
link when the coordinator announces.
|
||||
- Settings → Coordinator now shows that one-command installer instead of the
|
||||
long Docker command.
|
||||
- Documented the phone limit honestly: a phone can administer a coordinator or
|
||||
use Cordn’s app, but an always-on crew coordinator belongs on a small server.
|
||||
- No inbound port, domain or TLS terminator is required.
|
||||
|
||||
---
|
||||
|
||||
🔑 Simpler sign-in choices
|
||||
- The login dialog now shows two primary methods instead of three:
|
||||
**Browser extension** and a platform-aware signer card.
|
||||
- On Android the signer card is **Amber (NIP-55)**. On desktop it is
|
||||
**Pair a signer** via NIP-46 QR.
|
||||
- Removed the duplicate NIP-46 card. Desktop QR pairing now contains the
|
||||
“Paste a bunker URI instead” path, with a back link.
|
||||
- Amber users on Android get a direct “Use another signer (QR)” escape hatch.
|
||||
- Fixed the QR button markup and clarified that QR pairing is for NIP-46
|
||||
signers such as nsec.app/Nostrum; Amber is the one-tap Android path.
|
||||
|
||||
---
|
||||
|
||||
🛡️ Verify before showing, and stop leaking referrers
|
||||
- The shared skate-content query path now verifies every relay event's id and
|
||||
signature before merge/display. Trust-sensitive reads no longer rely on the
|
||||
relay implementation to have checked it.
|
||||
- User images and avatars now use `referrerPolicy="no-referrer"`; the site
|
||||
Referrer-Policy is `no-referrer` too, so third-party image hosts do not learn
|
||||
which SkateSpots page you came from.
|
||||
- These are defense-in-depth changes; no new user prompt or gate.
|
||||
|
||||
---
|
||||
|
||||
🎯 Status control no longer crowds the header
|
||||
- The desktop status control is now a compact emoji-only round chip with a
|
||||
tooltip, instead of a text pill that could collide with the profile controls.
|
||||
- Removed the duplicate online dot from the mobile avatar; the status indicator
|
||||
is the single source of truth there.
|
||||
- Existing status dialog, avatar shortcut and mobile menu behavior are unchanged.
|
||||
|
||||
---
|
||||
|
||||
🛡️ Amber is quieter and safer
|
||||
- Encrypted-message inboxes no longer auto-decrypt on arrival for Amber users.
|
||||
Messages now show a deliberate “Unlock messages” card; opening Messages itself
|
||||
does not fire a decrypt storm. The gate is Amber-only; silent signers keep the
|
||||
automatic inbox.
|
||||
- Amber redirect operations now expire after 10 minutes; stale callbacks are
|
||||
rejected and cleared, so an old request cannot be replayed later.
|
||||
- Redirect encryption operations no longer persist plaintext in localStorage,
|
||||
sessionStorage or IndexedDB. Crypto results are no longer left in browser
|
||||
storage.
|
||||
- Added regression tests for stale Amber callback handling.
|
||||
|
||||
---
|
||||
|
||||
🎯 Quiet status control
|
||||
- The desktop header no longer shows a large green outlined “Status” pill with a
|
||||
permanent ping. It now shows a compact chip with your current status emoji and
|
||||
label, or a neutral “Status” affordance when none is set.
|
||||
- The control stays a single tap to open the status dialog; the existing avatar
|
||||
status dot and mobile status flow are unchanged.
|
||||
|
||||
---
|
||||
|
||||
🧭 Coordinator setup that actually verifies itself
|
||||
- Settings → Coordinator now runs a real ContextVM RPC probe, not just a
|
||||
heartbeat presence check. It shows RPC latency when reachable and clearly
|
||||
distinguishes “visible but RPC unavailable”.
|
||||
- Crew edit now has an optional coordinator operator field (npub/hex), published
|
||||
in the existing coordinator tag. The operator is a label, not an admin key.
|
||||
- The RPC check uses a throwaway local key, so it never prompts Amber or touches
|
||||
your identity.
|
||||
- Added a live coordinator RPC test (skipped by default; `CORDN_LIVE=1`).
|
||||
|
||||
---
|
||||
|
||||
🏠 A real crew-owned coordinator kit
|
||||
- Added `deploy/cordn/`: Docker Compose with SQLite persistence, `.env` template,
|
||||
backup/restore scripts, and a runbook for a crew operator.
|
||||
- The coordinator needs only outbound access to ContextVM relays — no inbound
|
||||
port, domain or TLS terminator is required for the coordinator itself.
|
||||
- Settings → Coordinator and the crew edit form now link to the production kit;
|
||||
the displayed Docker command is persistent instead of the old `--rm` demo.
|
||||
- The app still does not embed a coordinator. One crew-trusted coordinator is
|
||||
the model; no multi-coordinator/BFT claims.
|
||||
|
||||
---
|
||||
|
||||
🎯 Value-aware safety and fresher reputation
|
||||
- High-value fixed-price listings (100,000 sats or 100 USD/EUR/GBP) now get the
|
||||
same compact safety note as new sellers: meet in public, prefer escrow for
|
||||
shipped deals, never pay outside the app, and remember escrow is not
|
||||
arbitration.
|
||||
- Trade reputation now applies a 180-day half-life to the trust/activity weight,
|
||||
while keeping lifetime counts and the existing distinct-partner/ring warnings.
|
||||
The profile card also shows how many mutually-confirmed trades happened in the
|
||||
last 90 days.
|
||||
- Added the unit-tested NUT-11 2-of-3 escrow policy primitive (seller+arbiter /
|
||||
buyer+arbiter). It is not exposed in the UI until an arbiter policy, bond and
|
||||
appeal process exist.
|
||||
|
||||
---
|
||||
|
||||
🔎 Relay consistency and recent reputation
|
||||
- Settings → Relays has an on-demand consistency check: it compares a bounded
|
||||
profile snapshot from the community relay with your read relays and reports
|
||||
newer/different signed profiles. It never merges data or treats relays as
|
||||
voters.
|
||||
- Trade reputation now shows how many mutually-confirmed trades happened in the
|
||||
last 90 days, alongside the lifetime count and existing distinct-partner
|
||||
warnings. Older reputation remains visible; this is a recency signal, not a
|
||||
reset.
|
||||
|
||||
---
|
||||
|
||||
🛡️ A clearer warning before a new seller deal
|
||||
- Listings from a seller with no confirmed trading partner, or an account under
|
||||
30 days old, now show a compact safety note with the marketplace guidance:
|
||||
meet in public, prefer escrow for shipped deals, and never pay outside the
|
||||
app’s rails.
|
||||
- The note sits beside the trade actions only — browsing stays frictionless.
|
||||
|
||||
---
|
||||
|
||||
🔒 Privacy, privacy, consistent merging
|
||||
- Relay results are now merged deterministically with a newest-event rule and a
|
||||
stable event-id tie-break instead of “first relay wins”. Local deletion
|
||||
tombstones are applied during the same merge, so a deleted item cannot flash
|
||||
back because a different relay answered first.
|
||||
- Photo uploads are redrawn through a canvas before Blossom, stripping EXIF/GPS
|
||||
metadata from decodable images. GIF and HEIC/HEIF still pass through as-is
|
||||
because they need special handling.
|
||||
- Android cloud backup is disabled for the app: local keys, settings and pending
|
||||
escrow state stay on the device and must be exported explicitly.
|
||||
- The OpenSats footer icon is now served locally, so opening the app does not
|
||||
contact opensats.org just to render the footer.
|
||||
- Added a technical threat-model document and linked it from the trust page:
|
||||
signed facts, no relay consensus, no delivery proof, no arbitration.
|
||||
|
||||
---
|
||||
|
||||
🧹 Small correctness pass
|
||||
- Unknown routes now return home with a real in-app link, so the native app no
|
||||
longer does a full page reload on a 404.
|
||||
- Profile badge links are sanitized before rendering, blocking `javascript:` and
|
||||
other unsafe URL schemes from user metadata.
|
||||
- Marketplace payment readiness now only says “ready” when checkout has a
|
||||
Lightning address it can actually pay; an LNURL-only profile is no longer
|
||||
advertised as ready.
|
||||
- Removed the duplicate North Korean option from the language picker while
|
||||
keeping stored preferences valid.
|
||||
- The beta screen’s “View the code” link now points at the self-hosted source
|
||||
instead of a GitHub search.
|
||||
|
||||
---
|
||||
|
||||
🧩 Cordn setup is easier to follow
|
||||
- The setup steps now show progress: preparing this device is checked once the
|
||||
key package is ready, and the approval step shows a spinner while waiting.
|
||||
- The private-chat header already shows the last successful check, so “live” and
|
||||
“still waiting” are distinguishable at a glance.
|
||||
|
||||
---
|
||||
|
||||
⚡ Cordn status, payment readiness, lighter photos
|
||||
- Cordn panels now show the last successful check next to the connection status,
|
||||
so “waiting” and “live” are easier to tell apart.
|
||||
- Sellers see a payment-readiness card in listing creation and on incoming
|
||||
offers, with a direct link to add a Lightning address before accepting a deal.
|
||||
- Photos are resized to max 1600px and converted to WebP before upload when that
|
||||
makes them smaller. GIFs and formats the WebView cannot decode are left alone.
|
||||
This cuts upload time, quota use and Blossom storage.
|
||||
|
||||
---
|
||||
|
||||
🧹 Spot owners can delete from the card menu
|
||||
- Owner/admin members now get a **Delete** action in the three-dot menu on
|
||||
spot and shop cards, opening the existing confirmation dialog.
|
||||
- Added a mobile **Back** arrow on every non-map screen, with history fallback
|
||||
to the map. The bottom Spots tab remains the home shortcut.
|
||||
- Trimmed the avatar account menu to purpose-built actions: status, profile,
|
||||
settings, account switching and logout. The larger navigation stays in the
|
||||
burger menu.
|
||||
- Brand wordmark hides on sub-pages so the Back arrow and header controls fit
|
||||
on narrow phones without truncation.
|
||||
|
||||
---
|
||||
|
||||
🧭 Brand logo opens your profile
|
||||
- The top-left SkateSpots lockup now opens your public profile when signed in
|
||||
(both mobile and desktop). Signed out, it still goes to the map.
|
||||
- The bottom Spots tab / desktop Map tab remains the home shortcut, so map
|
||||
access is unchanged.
|
||||
- The green avatar still opens your profile too; its chevron opens the account
|
||||
menu.
|
||||
|
||||
---
|
||||
|
||||
👤 Avatar opens your profile
|
||||
- The green user avatar in the mobile header now opens your public profile.
|
||||
- The small chevron beside it opens the account menu (status, add account,
|
||||
|
|
|
|||
BIN
resources/splash.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -55,6 +55,8 @@ interface Crew {
|
|||
pubkey: string;
|
||||
identifier: string;
|
||||
recipients: string[];
|
||||
coordinatorPubkey: string;
|
||||
coordinatorRelays: string[];
|
||||
}
|
||||
|
||||
interface WatcherState {
|
||||
|
|
@ -130,8 +132,18 @@ function crewFromEvent(event: {
|
|||
const name = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
if (!d || !name) return null;
|
||||
const coordinatorTag = event.tags.find(([name]) => name === 'coordinator');
|
||||
const coordinatorPubkey = coordinatorTag?.[2];
|
||||
if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null;
|
||||
const coordinatorUrl = coordinatorTag?.[1];
|
||||
const configuredPubkey = coordinatorTag?.[2];
|
||||
const isDefaultRelay = !!coordinatorUrl && coordinatorUrl === SKATESPOTS_COORDINATOR.relays[0];
|
||||
// A custom relay without a service pubkey is not addressable. Do not fall
|
||||
// back to SkateSpots' coordinator on someone else's relay.
|
||||
if (coordinatorUrl && !configuredPubkey && !isDefaultRelay) return null;
|
||||
const coordinatorPubkey = configuredPubkey ?? SKATESPOTS_COORDINATOR.pubkey;
|
||||
if (!/^[0-9a-f]{64}$/i.test(coordinatorPubkey)) return null;
|
||||
const coordinatorRelays =
|
||||
coordinatorUrl && /^wss?:\/\//i.test(coordinatorUrl)
|
||||
? [coordinatorUrl]
|
||||
: SKATESPOTS_COORDINATOR.relays;
|
||||
const admins = event.tags
|
||||
.filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin')
|
||||
.map(([, value]) => value.toLowerCase());
|
||||
|
|
@ -142,6 +154,8 @@ function crewFromEvent(event: {
|
|||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
recipients,
|
||||
coordinatorPubkey,
|
||||
coordinatorRelays,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -264,22 +278,44 @@ async function main(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const coordinator = new CordnCoordinatorClient({
|
||||
serverPubkey: SKATESPOTS_COORDINATOR.pubkey,
|
||||
relays: SKATESPOTS_COORDINATOR.relays,
|
||||
privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''),
|
||||
});
|
||||
|
||||
let requests: FetchManyPendingJoinRequestsOutput;
|
||||
try {
|
||||
requests = await coordinator.fetchManyPendingJoinRequests({
|
||||
groups: crews.map((crew) => ({ gid: crew.gid })),
|
||||
});
|
||||
} finally {
|
||||
await coordinator.disconnect().catch(() => undefined);
|
||||
const privateKeyHex = Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
const groupsByCoordinator = new Map<string, { pubkey: string; relays: string[]; crews: Crew[] }>();
|
||||
for (const crew of crews) {
|
||||
const key = `${crew.coordinatorPubkey}|${crew.coordinatorRelays.slice().sort().join(',')}`;
|
||||
const group = groupsByCoordinator.get(key) ?? {
|
||||
pubkey: crew.coordinatorPubkey,
|
||||
relays: crew.coordinatorRelays,
|
||||
crews: [],
|
||||
};
|
||||
group.crews.push(crew);
|
||||
groupsByCoordinator.set(key, group);
|
||||
}
|
||||
|
||||
if (requests.requests.length === 0) {
|
||||
const requests: FetchManyPendingJoinRequestsOutput['requests'] = [];
|
||||
for (const group of groupsByCoordinator.values()) {
|
||||
const coordinator = new CordnCoordinatorClient({
|
||||
serverPubkey: group.pubkey,
|
||||
relays: group.relays,
|
||||
privateKeyHex,
|
||||
});
|
||||
try {
|
||||
const result = await coordinator.fetchManyPendingJoinRequests({
|
||||
groups: group.crews.map((crew) => ({ gid: crew.gid })),
|
||||
});
|
||||
requests.push(...result.requests);
|
||||
} catch (error) {
|
||||
// A custom coordinator can be offline/private while other crews are
|
||||
// healthy. Keep polling the rest instead of failing the whole watcher.
|
||||
console.error(
|
||||
`[cordn-watcher] coordinator ${shortNpub(group.pubkey)} unavailable:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
await coordinator.disconnect().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (requests.length === 0) {
|
||||
pool.close([COMMUNITY]);
|
||||
console.log('[cordn-watcher] no pending join requests');
|
||||
return;
|
||||
|
|
@ -289,7 +325,7 @@ async function main(): Promise<void> {
|
|||
const state = loadState();
|
||||
let sent = 0;
|
||||
|
||||
for (const request of requests.requests) {
|
||||
for (const request of requests) {
|
||||
const crew = byGid.get(request.gid);
|
||||
if (!crew) continue;
|
||||
const key = `${request.gid}:${request.pk}:${request.kp_ref}`;
|
||||
|
|
@ -342,7 +378,7 @@ async function main(): Promise<void> {
|
|||
{
|
||||
watcher: nip19.npubEncode(watcherPubkey),
|
||||
crews: crews.length,
|
||||
pending: requests.requests.length,
|
||||
pending: requests.length,
|
||||
newRequests: sent,
|
||||
},
|
||||
null,
|
||||
|
|
|
|||
41
scripts/inject-backup-policy.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Disable Android Auto Backup for SkateSpots.
|
||||
*
|
||||
* The app stores identity material, local settings, pending escrow tokens and
|
||||
* other private state in its WebView storage. Android Auto Backup can copy that
|
||||
* data to a cloud account without the user ever asking for a backup. Keep it
|
||||
* device-local: SkateSpots has its own explicit export/backup flows.
|
||||
*
|
||||
* Idempotent; exits quietly when android/ has not been generated yet.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const manifestPath = join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.log('[inject-backup-policy] android/ not generated yet — skipping.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let manifest = readFileSync(manifestPath, 'utf8');
|
||||
const before = manifest;
|
||||
|
||||
if (/android:allowBackup="[^"]*"/.test(manifest)) {
|
||||
manifest = manifest.replace(/android:allowBackup="[^"]*"/, 'android:allowBackup="false"');
|
||||
} else {
|
||||
manifest = manifest.replace(
|
||||
/<application\b/,
|
||||
'<application android:allowBackup="false"',
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest === before) {
|
||||
console.log('[inject-backup-policy] allowBackup already disabled.');
|
||||
} else {
|
||||
writeFileSync(manifestPath, manifest);
|
||||
console.log('[inject-backup-policy] ✓ Disabled Android cloud backup (allowBackup=false).');
|
||||
}
|
||||
|
|
@ -15,7 +15,12 @@
|
|||
* 1. Removes any previously managed signing block.
|
||||
* 2. Removes a legacy hardcoded signingConfigs block (inline passwords).
|
||||
* 3. Writes a managed signingConfigs block into android/app/build.gradle that
|
||||
* reads secrets from the gitignored keystore.properties at the repo root.
|
||||
* reads secrets from a keystore.properties file outside the repo.
|
||||
*
|
||||
* The signing material is deliberately outside the Vite project root:
|
||||
* ~/.config/skatespots/android/keystore.properties
|
||||
* ~/.config/skatespots/android/skatespots-release.jks
|
||||
* Override the properties path with SKATESPOTS_KEYSTORE_PROPERTIES.
|
||||
*
|
||||
* It exits quietly (0) when android/ isn't generated yet, or when
|
||||
* keystore.properties is absent — so debug-only builds without a keystore still
|
||||
|
|
@ -23,11 +28,16 @@
|
|||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const buildGradle = join(root, 'android', 'app', 'build.gradle');
|
||||
const keystoreProps = join(root, 'keystore.properties');
|
||||
const keystoreProps = resolve(
|
||||
process.env.SKATESPOTS_KEYSTORE_PROPERTIES ??
|
||||
join(homedir(), '.config', 'skatespots', 'android', 'keystore.properties'),
|
||||
);
|
||||
const gradleString = (value) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
|
||||
const BEGIN = '// >>> managed by scripts/inject-signing-config.mjs';
|
||||
const END = '// <<< managed by scripts/inject-signing-config.mjs <<<';
|
||||
|
|
@ -43,10 +53,11 @@ if (!existsSync(keystoreProps)) {
|
|||
|
||||
const managedLines = [
|
||||
`${BEGIN} >>>`,
|
||||
'// Release signing is read from keystore.properties at the repo root',
|
||||
'// (gitignored). Edit that file, not this block.',
|
||||
'// Release signing is read from keystore.properties outside the repo.',
|
||||
'// Edit that file, not this block.',
|
||||
'def _keystoreProps = new Properties()',
|
||||
"def _keystorePropsFile = rootProject.file('../keystore.properties')",
|
||||
"def _keystorePropsPath = System.getenv('SKATESPOTS_KEYSTORE_PROPERTIES')",
|
||||
`def _keystorePropsFile = new File(_keystorePropsPath ?: '${gradleString(keystoreProps)}')`,
|
||||
'if (_keystorePropsFile.exists()) {',
|
||||
' _keystorePropsFile.withInputStream { _keystoreProps.load(it) }',
|
||||
'}',
|
||||
|
|
|
|||
|
|
@ -218,12 +218,23 @@ p ""
|
|||
p "${B}Community bot${N}"
|
||||
COMMUNITY_HOST="${SKATESPOTS_COMMUNITY_HOST:-skatespots-cvm}"
|
||||
if command -v ssh >/dev/null 2>&1 && grep -qsE "^Host[[:space:]]+${COMMUNITY_HOST}([[:space:]]|$)" "$HOME/.ssh/config"; then
|
||||
community_state="$(timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 "$COMMUNITY_HOST" 'systemctl is-active skatespots-community' 2>/dev/null || true)"
|
||||
if [ "$community_state" = "active" ]; then
|
||||
# A failed SSH connection is NOT evidence that the remote bot is down. The
|
||||
# previous version turned an empty SSH result into "unreachable" and emitted
|
||||
# an ops notification on every transient network/DNS hiccup. Retry once, then
|
||||
# report an honest unknown state without alerting.
|
||||
community_state=""
|
||||
for attempt in 1 2; do
|
||||
community_state="$(timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 "$COMMUNITY_HOST" 'systemctl is-active skatespots-community' 2>/dev/null || true)"
|
||||
[ -n "$community_state" ] && break
|
||||
[ "$attempt" = "1" ] && sleep 1
|
||||
done
|
||||
if [ -z "$community_state" ]; then
|
||||
p " ${Y}⚠${N} skatespots-community: health check unreachable (SSH failed; state unknown)"
|
||||
elif [ "$community_state" = "active" ]; then
|
||||
p " ${G}✓${N} skatespots-community active"
|
||||
else
|
||||
p " ${R}✗${N} skatespots-community: ${community_state:-unreachable}"
|
||||
problems+=("community Cordn bot is ${community_state:-unreachable}")
|
||||
p " ${R}✗${N} skatespots-community: ${community_state}"
|
||||
problems+=("community Cordn bot is ${community_state}")
|
||||
signature+="community-bot;"
|
||||
fi
|
||||
community_backup="$(systemctl --user is-active skatespots-community-backup.timer 2>/dev/null || echo unknown)"
|
||||
|
|
|
|||
108
scripts/publish-forgejo.sh
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Publish a built APK to the self-hosted Forgejo release page.
|
||||
#
|
||||
# Usage:
|
||||
# FORGEJO_TOKEN=... scripts/publish-forgejo.sh 1.7.62 [path/to.apk]
|
||||
# FORGEJO_PASSWORD=... FORGEJO_USER=skatespots scripts/publish-forgejo.sh 1.7.62
|
||||
#
|
||||
# This uploads the APK and its .sha256 file. It does not push the git tag or
|
||||
# source; do that separately (the Forgejo repo is a mirror, not an auto-push
|
||||
# remote). Keep credentials in your shell/environment, never in this repo.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: publish-forgejo.sh <version> [apk]}"
|
||||
APK="${2:-android/app/build/outputs/apk/release/app-release.apk}"
|
||||
TAG="${FORGEJO_TAG:-v$VERSION}"
|
||||
BASE="${FORGEJO_URL:-https://git.skate-spots.org}"
|
||||
OWNER="${FORGEJO_OWNER:-skatespots}"
|
||||
REPO="${FORGEJO_REPO:-SkateSpots}"
|
||||
API="$BASE/api/v1/repos/$OWNER/$REPO"
|
||||
|
||||
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
|
||||
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; }
|
||||
[ -f "$APK" ] || { echo "APK not found: $APK" >&2; exit 1; }
|
||||
|
||||
AUTH=()
|
||||
if [ -n "${FORGEJO_TOKEN:-}" ]; then
|
||||
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
|
||||
elif [ -n "${FORGEJO_PASSWORD:-}" ]; then
|
||||
AUTH=(-u "${FORGEJO_USER:-skatespots}:$FORGEJO_PASSWORD")
|
||||
else
|
||||
echo "set FORGEJO_TOKEN (preferred) or FORGEJO_PASSWORD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
sha="$(sha256sum "$APK" | awk '{print $1}')"
|
||||
body="$tmpdir/release.md"
|
||||
cat >"$body" <<EOF
|
||||
## SkateSpots $VERSION
|
||||
|
||||
Signed Android release. SHA-256: \`$sha\`
|
||||
|
||||
- Source: $BASE/$OWNER/$REPO
|
||||
- APK signing key: same as the Zapstore release.
|
||||
EOF
|
||||
|
||||
python3 - "$TAG" "$VERSION" "$body" >"$tmpdir/payload.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
tag, version, body_path = sys.argv[1:]
|
||||
print(json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": version,
|
||||
"body": Path(body_path).read_text(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
|
||||
create_code="$(curl -sS -o "$tmpdir/release.json" -w '%{http_code}' \
|
||||
"${AUTH[@]}" -H 'Content-Type: application/json' \
|
||||
-X POST "$API/releases" --data-binary @"$tmpdir/payload.json" || true)"
|
||||
|
||||
if [ "$create_code" = "201" ]; then
|
||||
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
|
||||
elif [ "$create_code" = "409" ] || [ "$create_code" = "422" ]; then
|
||||
curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" -o "$tmpdir/release.json"
|
||||
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
|
||||
else
|
||||
echo "Forgejo release creation failed (HTTP $create_code)" >&2
|
||||
cat "$tmpdir/release.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sha_file="$tmpdir/SkateSpots-$VERSION.apk.sha256"
|
||||
printf '%s SkateSpots-%s.apk\n' "$sha" "$VERSION" >"$sha_file"
|
||||
|
||||
existing_assets="$(python3 - "$tmpdir/release.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
print("\n".join(asset["name"] for asset in data.get("assets", [])))
|
||||
PY
|
||||
)"
|
||||
|
||||
for file in "$APK" "$sha_file"; do
|
||||
name="$(basename "$file")"
|
||||
if printf '%s\n' "$existing_assets" | grep -Fqx "$name"; then
|
||||
echo "already present: $name"
|
||||
continue
|
||||
fi
|
||||
code="$(curl -sS -o "$tmpdir/asset.json" -w '%{http_code}' \
|
||||
"${AUTH[@]}" -X POST "$API/releases/$release_id/assets?name=$name" \
|
||||
-F "attachment=@$file" || true)"
|
||||
case "$code" in
|
||||
201|202) echo "uploaded $name" ;;
|
||||
*) echo "warning: upload of $name returned HTTP $code" >&2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "release: $BASE/$OWNER/$REPO/releases/tag/$TAG"
|
||||
echo "latest: $BASE/$OWNER/$REPO/releases/latest"
|
||||
|
|
@ -126,6 +126,14 @@ for _ in 1 2 3 4 5 6; do
|
|||
done
|
||||
[ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)"
|
||||
|
||||
if [ -n "${FORGEJO_TOKEN:-}" ] || [ -n "${FORGEJO_PASSWORD:-}" ]; then
|
||||
log "publishing the APK to the self-hosted Forgejo release page…"
|
||||
scripts/publish-forgejo.sh "$VERSION" "$APK" || \
|
||||
log "warning: Forgejo upload failed — the Zapstore release is still valid"
|
||||
else
|
||||
log "Forgejo: skipped (set FORGEJO_TOKEN or FORGEJO_PASSWORD to publish there)"
|
||||
fi
|
||||
|
||||
log "committing the release…"
|
||||
git add package.json package-lock.json release-notes.txt
|
||||
git commit -m "release: $VERSION" >/dev/null
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import NostrProvider from '@/components/NostrProvider';
|
|||
import { NostrSync } from '@/components/NostrSync';
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { NostrLoginProvider } from '@nostrify/react/login';
|
||||
import { NostrLoginProvider } from '@/lib/nostrLogin';
|
||||
import { AppProvider } from '@/components/AppProvider';
|
||||
import { NWCProvider } from '@/contexts/NWCContext';
|
||||
import { LanguageProvider } from '@/contexts/LanguageContext';
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
|
|||
const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage"));
|
||||
const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage"));
|
||||
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
|
||||
const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage"));
|
||||
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
|
||||
const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage"));
|
||||
const NotFound = lazyWithRetry(() => import("./pages/NotFound"));
|
||||
|
|
@ -200,7 +199,7 @@ export function AppRouter() {
|
|||
<Route path="/support" element={<Navigate to="/supporters" replace />} />
|
||||
<Route path="/donate" element={<DonatePage />} />
|
||||
{/* Marketplace */}
|
||||
<Route path="/marketplace-listings" element={<MarketplacePage />} />
|
||||
<Route path="/marketplace-listings" element={<Navigate to="/marketplace" replace />} />
|
||||
{/* Newsletter */}
|
||||
<Route path="/newsletter" element={<NewsletterPage />} />
|
||||
{/* Trick Glossary */}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { KeyRound } from 'lucide-react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
||||
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ import { useTranslation } from '@/contexts/language';
|
|||
const DISCLAIMER_VERSION = 1;
|
||||
const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`;
|
||||
|
||||
const REPO_URL =
|
||||
'https://github.com/search?q=SkateSpots';
|
||||
const REPO_URL = 'https://git.skate-spots.org/skatespots/SkateSpots';
|
||||
|
||||
export function BetaDisclaimerDialog() {
|
||||
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ import { useTranslation } from '@/contexts/language';
|
|||
import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
CORDN_APP_URL,
|
||||
CORDN_DOCKER_COMMAND,
|
||||
CORDN_INSTALL_COMMAND,
|
||||
CORDN_DEPLOY_KIT_URL,
|
||||
CORDN_RELAY_URL,
|
||||
coordinatorAddUrl,
|
||||
parseCoordinatorInput,
|
||||
|
|
@ -94,7 +95,7 @@ function RelayHealthPill({ url }: { url: string }) {
|
|||
export function CoordinatorSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
|
||||
const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator);
|
||||
const { status, lastSeen, eventCount, name, error, rpcStatus, rpcLatencyMs, check } = useCoordinatorHealth(coordinator);
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
|
|
@ -175,6 +176,17 @@ export function CoordinatorSettingsCard() {
|
|||
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
|
||||
</p>
|
||||
|
||||
{rpcStatus === 'ok' && (
|
||||
<p className="text-[11px] font-medium text-green-400">
|
||||
{t('socialUi.coordinator.rpcOk', { ms: rpcLatencyMs ?? 0 })}
|
||||
</p>
|
||||
)}
|
||||
{rpcStatus === 'error' && (
|
||||
<p className="text-[11px] text-amber-400">
|
||||
{t('socialUi.coordinator.rpcError')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
|
||||
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
|
||||
|
|
@ -303,17 +315,25 @@ export function CoordinatorSettingsCard() {
|
|||
</p>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
|
||||
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
|
||||
{CORDN_DOCKER_COMMAND}
|
||||
{CORDN_INSTALL_COMMAND}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(CORDN_DOCKER_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
|
||||
onClick={() => void copyText(CORDN_INSTALL_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
|
||||
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
|
||||
aria-label={t('socialUi.coordinator.copyCommand')}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a href={CORDN_DEPLOY_KIT_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button size="sm" variant="outline">
|
||||
{t('common.learnMore')}
|
||||
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
{t('socialUi.coordinator.androidSleep')}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { useCrewGoal } from '@/hooks/useCrewGoal';
|
|||
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
||||
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
||||
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR } from '@/lib/coordinator';
|
||||
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, parseOperatorPubkey, shortNpub, SKATESPOTS_COORDINATOR, CORDN_DEPLOY_KIT_URL } from '@/lib/coordinator';
|
||||
import { useRelays } from '@/lib/relays';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react';
|
||||
|
|
@ -336,6 +336,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
crewCoordinatorInputValue(existing?.coordinator) ||
|
||||
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
|
||||
);
|
||||
const [coordinatorOperator, setCoordinatorOperator] = useState(existing?.coordinator?.operator ?? '');
|
||||
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
|
||||
const { coordinator: myCoordinator } = useCoordinator();
|
||||
// Live parse so the founder gets feedback (and the publish path reuses it).
|
||||
|
|
@ -494,6 +495,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const operatorPubkey = coordinatorOperator.trim() ? parseOperatorPubkey(coordinatorOperator) : null;
|
||||
if (coordinatorOperator.trim() && !operatorPubkey) {
|
||||
toast({ title: t('socialUi.coordinator.invalidOperator'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
||||
|
|
@ -614,6 +620,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
if (parsedCoordinator) {
|
||||
const coordinatorTag = ['coordinator', parsedCoordinator.url];
|
||||
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
|
||||
if (operatorPubkey) {
|
||||
if (!parsedCoordinator.pubkey) coordinatorTag.push('');
|
||||
coordinatorTag.push(operatorPubkey);
|
||||
}
|
||||
tags.push(coordinatorTag);
|
||||
}
|
||||
|
||||
|
|
@ -1184,6 +1194,15 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
>
|
||||
{t('socialUi.coordinator.cordnCoordinator')}
|
||||
</a>
|
||||
{' · '}
|
||||
<a
|
||||
href={CORDN_DEPLOY_KIT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
|
||||
>
|
||||
{t('common.learnMore')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -1233,6 +1252,21 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</Button>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="crew-coordinator-operator" className="text-xs text-gray-400">
|
||||
{t('socialUi.coordinator.operatorLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="crew-coordinator-operator"
|
||||
placeholder={t('socialUi.coordinator.operatorPlaceholder')}
|
||||
value={coordinatorOperator}
|
||||
onChange={(e) => setCoordinatorOperator(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-[10px] text-gray-600">{t('socialUi.coordinator.operatorHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
|
@ -52,6 +53,13 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
const [showMarkSold, setShowMarkSold] = useState(false);
|
||||
|
||||
const npub = nip19.npubEncode(item.pubkey);
|
||||
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
|
||||
// Keep shared links canonical in the native WebView too (capacitor://localhost
|
||||
// is not a shareable origin).
|
||||
const listingUrl = `https://skate-spots.org${listingPath}`;
|
||||
const contactSeller = () => navigate(
|
||||
`/messages/${npub}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
|
||||
);
|
||||
const isOwnListingCheck = user?.pubkey === item.pubkey;
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
|
||||
|
|
@ -127,21 +135,28 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
|
||||
return (
|
||||
<Card
|
||||
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] h-full ${
|
||||
className={`group relative h-full border-2 bg-black/90 transition-all hover:scale-[1.02] ${
|
||||
isWTB
|
||||
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
|
||||
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
|
||||
}`}
|
||||
onClick={() => navigate(`/listing/${item.pubkey}/${item.identifier}`)}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
{/* Stretched link: real link semantics, context menu, middle-click and
|
||||
keyboard activation. Interactive controls above re-enable pointer events. */}
|
||||
<Link
|
||||
to={listingPath}
|
||||
aria-label={t('marketUi.card.viewTitle', { title: item.title })}
|
||||
className="absolute inset-0 z-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
|
||||
/>
|
||||
<CardContent className="pointer-events-none relative z-10 p-0">
|
||||
{/* Image */}
|
||||
<div className="relative aspect-square overflow-hidden rounded-t-lg">
|
||||
{primaryImage ? (
|
||||
<img
|
||||
<ImageWithFallback
|
||||
src={primaryImage}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
fallback={<span className="text-6xl">{isWTB ? '🔍' : '🛹'}</span>}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
|
@ -198,7 +213,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
{/* More options menu — owners manage their listing (mark sold /
|
||||
relist / delete), non-owners can report, admins can take down. */}
|
||||
{user && (
|
||||
<div className="absolute bottom-2 right-2 z-20">
|
||||
<div className="pointer-events-auto absolute bottom-2 right-2 z-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -358,7 +373,8 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
|
||||
onClick={() => navigate(listingPath)}
|
||||
className={`pointer-events-auto cursor-pointer text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
|
||||
>
|
||||
{trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />}
|
||||
{trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />}
|
||||
|
|
@ -378,7 +394,10 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className="pointer-events-auto flex cursor-pointer items-center gap-1"
|
||||
onClick={() => navigate(listingPath)}
|
||||
>
|
||||
{sellerRating.rating > 0 ? (
|
||||
<span className="text-sm">{sellerRating.display}</span>
|
||||
) : (
|
||||
|
|
@ -403,14 +422,14 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`w-full mt-3 gap-2 ${
|
||||
className={`pointer-events-auto mt-3 w-full gap-2 ${
|
||||
isWTB
|
||||
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
||||
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/messages/${npub}`);
|
||||
contactSeller();
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function ImageWithFallback({
|
|||
onError,
|
||||
loading = 'lazy',
|
||||
decoding = 'async',
|
||||
referrerPolicy = 'no-referrer',
|
||||
...props
|
||||
}: ImageWithFallbackProps) {
|
||||
const candidates = useMemo(() => imageCandidates(src), [src]);
|
||||
|
|
@ -67,6 +68,7 @@ export function ImageWithFallback({
|
|||
className={className}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
referrerPolicy={referrerPolicy}
|
||||
onError={(event) => {
|
||||
if (index + 1 < candidates.length) {
|
||||
setIndex(index + 1);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { useTranslation } from '@/contexts/language';
|
|||
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const VISIBLE_LANGUAGE_OPTIONS = LANGUAGE_OPTIONS.filter((language) => !language.hidden);
|
||||
|
||||
interface LanguageToggleProps {
|
||||
/** Show only the flag icon (compact mode) */
|
||||
compact?: boolean;
|
||||
|
|
@ -65,13 +67,13 @@ export function LanguageToggle({ compact = false, inline = false, className, var
|
|||
|
||||
const safeQuery = query.trim().toLowerCase();
|
||||
const filteredLanguages = safeQuery
|
||||
? LANGUAGE_OPTIONS.filter(
|
||||
? VISIBLE_LANGUAGE_OPTIONS.filter(
|
||||
(l) =>
|
||||
l.name.toLowerCase().includes(safeQuery) ||
|
||||
l.nativeName.toLowerCase().includes(safeQuery) ||
|
||||
l.code.toLowerCase().includes(safeQuery)
|
||||
)
|
||||
: LANGUAGE_OPTIONS;
|
||||
: VISIBLE_LANGUAGE_OPTIONS;
|
||||
|
||||
const currentLanguage =
|
||||
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
|
||||
|
|
@ -196,7 +198,7 @@ export function LanguageSelector() {
|
|||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
{VISIBLE_LANGUAGE_OPTIONS.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => setLanguage(lang.code)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
|
|
@ -39,7 +39,6 @@ import {
|
|||
User,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Package,
|
||||
Heart,
|
||||
CreditCard,
|
||||
|
|
@ -53,10 +52,13 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Search,
|
||||
SmilePlus,
|
||||
} from 'lucide-react';
|
||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||
import { SKATING_STATUSES, useUserStatus } from '@/hooks/useUserStatus';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -235,8 +237,6 @@ function MobileHeaderUserMenu({
|
|||
</Avatar>
|
||||
{/* Status indicator */}
|
||||
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
||||
{/* Green online indicator dot */}
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-black bg-green-500 xs:hidden" />
|
||||
</Link>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
@ -283,12 +283,6 @@ function MobileHeaderUserMenu({
|
|||
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
||||
<span className="ml-auto text-xs text-green-500">🛹</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
|
||||
<Plus className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00] font-medium">{t('chromeUi.addSpot')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||||
<User className="h-4 w-4" />
|
||||
|
|
@ -301,36 +295,6 @@ function MobileHeaderUserMenu({
|
|||
{t('nav.settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
|
||||
<Database className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">{t('chromeUi.myData')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
|
||||
<Package className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-purple-400">{t('chromeUi.myStash')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
|
||||
<Wallet className="h-4 w-4 text-orange-400" />
|
||||
<span className="text-orange-400">{t('nav.wallet')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
|
||||
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">{t('nav.leaderboards')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
|
||||
<Package className="h-4 w-4 text-orange-400" />
|
||||
<span className="text-orange-400">{t('nav.marketplace')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -464,7 +428,7 @@ function MobileNav({
|
|||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
|
||||
'block w-full truncate text-center text-[10px] xs:text-[11px] leading-tight tracking-wide uppercase transition-all',
|
||||
active ? 'font-extrabold' : 'font-semibold',
|
||||
)}>
|
||||
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||||
|
|
@ -510,8 +474,49 @@ function MobileNav({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact desktop status control. The old outlined pill + permanent ping read
|
||||
* like an alert; this shows the current status as a quiet chip and falls back
|
||||
* to a neutral "Status" affordance when none is set.
|
||||
*/
|
||||
function DesktopStatusButton({ onOpen }: { onOpen: () => void }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { data: status } = useUserStatus(user?.pubkey);
|
||||
const preset = status?.statusId
|
||||
? SKATING_STATUSES.find((item) => item.id === status.statusId)
|
||||
: null;
|
||||
const label = preset ? t(preset.labelKey) : status?.status;
|
||||
const emoji = status?.emoji || preset?.emoji;
|
||||
const title = status && label
|
||||
? `${label} — ${t('chromeUi.setYourStatus')}`
|
||||
: t('chromeUi.setYourStatus');
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpen}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'h-9 w-9 shrink-0 rounded-full border border-white/10 p-0 text-gray-300',
|
||||
'hover:border-green-500/30 hover:bg-green-500/10 hover:text-green-300',
|
||||
status && 'border-green-500/20 bg-green-500/5 text-green-200',
|
||||
)}
|
||||
>
|
||||
{emoji ? (
|
||||
<span className="text-base leading-none" aria-hidden="true">{emoji}</span>
|
||||
) : (
|
||||
<SmilePlus className="h-4 w-4 text-green-400/80" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout({ children, noPadding }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const showMobileBack = location.pathname !== '/' && location.pathname !== '/map';
|
||||
// The map is full-bleed (it manages its own height), so it gets no bottom
|
||||
// padding even though it shares this one persistent layout with every route.
|
||||
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
|
||||
|
|
@ -619,9 +624,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8">
|
||||
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the
|
||||
logo never gets squeezed into (or glowed over by) the nav. */}
|
||||
<Link to="/" className="flex items-center gap-3 group shrink-0">
|
||||
<Link
|
||||
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
|
||||
className="flex items-center gap-3 group shrink-0"
|
||||
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
|
||||
>
|
||||
<img
|
||||
src="/images/icon-1024.png"
|
||||
src="/images/logo.png"
|
||||
alt="SkateSpots"
|
||||
className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
|
||||
/>
|
||||
|
|
@ -782,20 +791,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
</Link>
|
||||
)}
|
||||
{/* Status Quick Access - Desktop */}
|
||||
{user && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
|
||||
onClick={() => setShowStatusDialog(true)}
|
||||
title={t('chromeUi.setYourStatus')}
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||
</span>
|
||||
<span className="text-sm font-bold">{t('chromeUi.status')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{user && <DesktopStatusButton onOpen={() => setShowStatusDialog(true)} />}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -838,13 +834,34 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
|
||||
<div className="flex items-center justify-between h-14 px-3 gap-3">
|
||||
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0">
|
||||
{showMobileBack && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-ml-1 h-9 w-9 shrink-0 text-gray-300 hover:bg-white/5 hover:text-white"
|
||||
aria-label={t('common.back')}
|
||||
title={t('common.back')}
|
||||
onClick={() => {
|
||||
if (location.key !== 'default') navigate(-1);
|
||||
else navigate('/');
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
|
||||
className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0"
|
||||
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
|
||||
aria-label={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
|
||||
>
|
||||
<img
|
||||
src="/images/icon-1024.png"
|
||||
src="/images/logo.png"
|
||||
alt="SkateSpots"
|
||||
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
||||
/>
|
||||
<span className="min-w-0 truncate font-display text-lg neon-red tracking-wide" style={{
|
||||
<span className={cn('min-w-0 truncate font-display text-lg neon-red tracking-wide', showMobileBack && 'hidden')} style={{
|
||||
textShadow: '0 0 8px #ff0040'
|
||||
}}>SKATESPOTS</span>
|
||||
</Link>
|
||||
|
|
@ -1232,7 +1249,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<span>{t('chromeUi.notSponsoredBy')}</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<img
|
||||
src="https://opensats.org/favicon.ico"
|
||||
src="/images/opensats.ico"
|
||||
alt="OpenSats"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { genUserName } from '@/lib/genUserName';
|
|||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { PayWithSats } from '@/components/PayWithSats';
|
||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||||
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
|
@ -183,6 +184,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
|
||||
</h3>
|
||||
|
||||
{isOwner && <PaymentReadinessNote />}
|
||||
|
||||
{mine.map((thread) => {
|
||||
const latest = thread.latest;
|
||||
const other = latest.author === user.pubkey
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
|
||||
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
||||
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
|
||||
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
|
||||
const listingUrl = `https://skate-spots.org${listingPath}`;
|
||||
const contactSeller = () => navigate(
|
||||
`/messages/${nip19.npubEncode(item.pubkey)}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
|
||||
);
|
||||
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
||||
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||
|
|
@ -305,7 +310,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
||||
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
||||
}`}
|
||||
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)}
|
||||
onClick={contactSeller}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function MapFooterStrip() {
|
|||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1.5 text-gray-500">
|
||||
{t('chromeUi.notSponsoredBy')}
|
||||
<img src="https://opensats.org/favicon.ico" alt="OpenSats" className="h-3 w-3" />
|
||||
<img src="/images/opensats.ico" alt="OpenSats" className="h-3 w-3" />
|
||||
<span className="font-medium text-gray-400">OpenSats</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ export function PaymentReadinessNote() {
|
|||
const metadata = author.data?.metadata;
|
||||
|
||||
const lud16 = metadata?.lud16;
|
||||
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06;
|
||||
// PayWithSats falls back to the profile's lud16. A lud06/LNURL alone is
|
||||
// not enough to settle a marketplace offer, so do not advertise readiness
|
||||
// for a payment path checkout cannot actually use.
|
||||
const hasLightning = isValidLightningAddress(lud16);
|
||||
// Silent payment lives in a custom kind-0 field the app reads elsewhere.
|
||||
const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment;
|
||||
const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp);
|
||||
|
|
@ -58,7 +61,7 @@ export function PaymentReadinessNote() {
|
|||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
|
||||
<span>
|
||||
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
|
||||
<Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300">
|
||||
<Link to="/settings?section=profile" className="text-yellow-400 underline hover:text-yellow-300">
|
||||
{t('marketUi.paymentReadiness.nostrProfile')}
|
||||
</Link>{' '}
|
||||
{t('marketUi.paymentReadiness.notReadyAfter')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { BadgeCheck, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** The app logo is already served with the site. */
|
||||
const SKATESPOTS_LOGO = '/images/logo-icon.png';
|
||||
|
|
@ -26,6 +27,7 @@ export function ProfileBadges({
|
|||
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
||||
{badges.map((b, i) => {
|
||||
const app = isApp(b);
|
||||
const safeUrl = b.url ? sanitizeUrl(b.url) : null;
|
||||
const cls = cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors',
|
||||
size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm',
|
||||
|
|
@ -41,17 +43,17 @@ export function ProfileBadges({
|
|||
<BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
|
||||
)}
|
||||
<span className="max-w-[12rem] truncate">{b.label}</span>
|
||||
{b.url && !app && <ExternalLink className="h-3 w-3 opacity-60" />}
|
||||
{safeUrl && !app && <ExternalLink className="h-3 w-3 opacity-60" />}
|
||||
</>
|
||||
);
|
||||
|
||||
return b.url ? (
|
||||
return safeUrl ? (
|
||||
<a
|
||||
key={`${b.label}-${i}`}
|
||||
href={b.url}
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={b.url}
|
||||
title={safeUrl}
|
||||
className={cls}
|
||||
>
|
||||
{inner}
|
||||
|
|
|
|||
69
src/components/RelayDivergenceCard.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Activity, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useRelayDivergence } from '@/hooks/useRelayDivergence';
|
||||
|
||||
/**
|
||||
* Settings diagnostic: compare the community relay with the user's read relays.
|
||||
* No merge, no vote, no data mutation.
|
||||
*/
|
||||
export function RelayDivergenceCard() {
|
||||
const { t } = useTranslation();
|
||||
const { result, checking, error, check } = useRelayDivergence();
|
||||
|
||||
const differences = result?.differences.length ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="bg-black/90 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-bold text-white">
|
||||
<Activity className="h-4 w-4 text-[#ccff00]" />
|
||||
{t('settingsUi.relayList.relayDiagTitle')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs leading-relaxed text-gray-400">
|
||||
{t('settingsUi.relayList.relayDiagDesc')}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void check()}
|
||||
disabled={checking}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={checking ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'} />
|
||||
{checking
|
||||
? t('settingsUi.relayList.relayDiagChecking')
|
||||
: t('settingsUi.relayList.relayDiagCheck')}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400">
|
||||
{t('settingsUi.relayList.relayDiagFailed')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{result && !error && (
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className={differences > 0 ? 'font-bold text-amber-300' : 'text-green-400'}>
|
||||
{differences > 0
|
||||
? t('settingsUi.relayList.relayDiagDiff', { count: differences })
|
||||
: t('settingsUi.relayList.relayDiagAgree')}
|
||||
</p>
|
||||
{differences > 0 && (
|
||||
<p className="text-gray-500">{t('settingsUi.relayList.relayDiagDetails')}</p>
|
||||
)}
|
||||
{result.errors.length > 0 && (
|
||||
<p className="text-gray-500">
|
||||
{result.errors.length} {t('settingsUi.relayList.offline')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,12 @@ import {
|
|||
import { getLastLocation } from '@/lib/lastLocation';
|
||||
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
||||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||
import {
|
||||
MAP_TILE_URL,
|
||||
MAP_TILE_SUBDOMAINS,
|
||||
MAP_TILE_CACHE_NAMESPACE,
|
||||
MAP_TILE_ATTRIBUTION,
|
||||
} from '@/lib/mapTiles';
|
||||
import { calculateDistance } from '@/lib/sortOptions';
|
||||
import {
|
||||
fetchRoute,
|
||||
|
|
@ -598,13 +604,13 @@ export function RouteDialog({
|
|||
minZoom: 2,
|
||||
});
|
||||
|
||||
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '',
|
||||
subdomains: 'abc',
|
||||
createCachedTileLayer(MAP_TILE_URL, {
|
||||
attribution: MAP_TILE_ATTRIBUTION,
|
||||
subdomains: MAP_TILE_SUBDOMAINS,
|
||||
maxZoom: 19,
|
||||
noWrap: true,
|
||||
className: 'skatespots-dark-tiles',
|
||||
}).addTo(map);
|
||||
}, MAP_TILE_CACHE_NAMESPACE).addTo(map);
|
||||
|
||||
// Metric scale bar — genuinely useful on a route map (how long is that line?).
|
||||
L.control.scale({ imperial: false, position: 'topleft' }).addTo(map);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { QRCodeCanvas } from '@/components/ui/qrcode';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { backupConfirmedKey } from '@/lib/backup';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ export function SplashScreen() {
|
|||
className="relative"
|
||||
>
|
||||
<img
|
||||
src="/images/icon-1024.png"
|
||||
src="/images/logo.png"
|
||||
alt="SkateSpots"
|
||||
draggable={false}
|
||||
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react';
|
||||
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||||
|
|
@ -23,6 +23,7 @@ import { OfficialBadge } from '@/components/OfficialBadge';
|
|||
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||||
import type { SkateSpot } from '@/lib/types';
|
||||
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } from '@/lib/types';
|
||||
|
|
@ -30,6 +31,7 @@ import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
|||
import { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
||||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SpotCardProps {
|
||||
|
|
@ -48,10 +50,13 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
|||
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
||||
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Determine if this is a shop or a spot
|
||||
const isShop = spot.types.includes('shop');
|
||||
const isOwner = user?.pubkey === spot.pubkey;
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
const canDelete = !!user && (isOwner || isAdmin);
|
||||
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
|
||||
|
||||
// Shops that actually issue access passes get a badge (one shared, cached query).
|
||||
|
|
@ -245,7 +250,19 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
|||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{isOwner ? t('common.delete') : t('spotUi.removeAdmin')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
|
|
@ -275,6 +292,17 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Owner/admin delete confirmation */}
|
||||
{canDelete && (
|
||||
<DeleteEntityDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
event={spot.event}
|
||||
entityLabel={isShop ? t('mapUi.skateShop') : t('spotUi.spot')}
|
||||
entityName={spot.title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Report Dialog */}
|
||||
<ReportDialog
|
||||
open={showReportDialog}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { useEffect, useRef } from 'react';
|
|||
import L from 'leaflet';
|
||||
|
||||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||
import {
|
||||
MAP_TILE_URL,
|
||||
MAP_TILE_SUBDOMAINS,
|
||||
MAP_TILE_CACHE_NAMESPACE,
|
||||
MAP_TILE_ATTRIBUTION,
|
||||
} from '@/lib/mapTiles';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface LatLng {
|
||||
|
|
@ -53,14 +59,14 @@ export function SpotLocationPicker({ value, onChange, className }: SpotLocationP
|
|||
attributionControl: true,
|
||||
});
|
||||
|
||||
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '',
|
||||
subdomains: 'abc',
|
||||
createCachedTileLayer(MAP_TILE_URL, {
|
||||
attribution: MAP_TILE_ATTRIBUTION,
|
||||
subdomains: MAP_TILE_SUBDOMAINS,
|
||||
minZoom: 2,
|
||||
maxZoom: 19,
|
||||
noWrap: true,
|
||||
className: 'skatespots-dark-tiles',
|
||||
}).addTo(map);
|
||||
}, MAP_TILE_CACHE_NAMESPACE).addTo(map);
|
||||
|
||||
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||
onChangeRef.current({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
|||
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||
import {
|
||||
MAP_TILE_URL,
|
||||
MAP_TILE_SUBDOMAINS,
|
||||
MAP_TILE_CACHE_NAMESPACE,
|
||||
MAP_TILE_ATTRIBUTION,
|
||||
} from '@/lib/mapTiles';
|
||||
import { FORECAST_DAYS } from '@/lib/weather';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
|
@ -955,17 +961,18 @@ export function SpotMap({
|
|||
|
||||
applyWorldMinZoom(map);
|
||||
|
||||
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
|
||||
// filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches
|
||||
// the app's dark theme without depending on a keyed provider like CARTO.
|
||||
// Wrapped in an on-device tile cache so the basemap still draws at the park.
|
||||
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '',
|
||||
subdomains: 'abc',
|
||||
// OpenStreetMap standard tiles, darkened via a CSS filter (see the
|
||||
// `.skatespots-dark-tiles` rule in index.css) so they match the app's dark
|
||||
// theme. Wrapped in an on-device tile cache so the basemap still draws at
|
||||
// the park, with a fresh cache namespace so old access-blocked tiles are
|
||||
// never reused.
|
||||
createCachedTileLayer(MAP_TILE_URL, {
|
||||
attribution: MAP_TILE_ATTRIBUTION,
|
||||
subdomains: MAP_TILE_SUBDOMAINS,
|
||||
minZoom: 1,
|
||||
maxZoom: 19,
|
||||
className: 'skatespots-dark-tiles',
|
||||
}).addTo(map);
|
||||
}, MAP_TILE_CACHE_NAMESPACE).addTo(map);
|
||||
|
||||
// Add click handler if enabled
|
||||
if (enableClickToAdd && onMapClick) {
|
||||
|
|
|
|||
|
|
@ -48,9 +48,20 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
|||
// Activity factor: a fresh account with no published content contributes ~0,
|
||||
// so a ring of empty accounts can't move the score even if cross-confirmed.
|
||||
const activityFactor = (pk: string) => Math.min(1, (activity?.get(pk) ?? 0) / 3);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayFactor = (v: { lastAt: number; recentCount: number }) => {
|
||||
// 180-day half-life, floored so a good historical record is still visible.
|
||||
// This changes the ranking weight only; lifetime counts stay unchanged.
|
||||
const ageDays = Math.max(0, (now - v.lastAt) / 86_400);
|
||||
return Math.max(0.25, Math.exp(-ageDays / 180));
|
||||
};
|
||||
const weighted = verifiedWith.reduce(
|
||||
(sum, v) =>
|
||||
sum + Math.min(v.count, 3) * ((scores?.get(v.pubkey) ?? 0) / 100) * activityFactor(v.pubkey),
|
||||
sum +
|
||||
Math.min(v.count, 3) *
|
||||
((scores?.get(v.pubkey) ?? 0) / 100) *
|
||||
activityFactor(v.pubkey) *
|
||||
decayFactor(v),
|
||||
0,
|
||||
);
|
||||
const trustLabel =
|
||||
|
|
@ -79,6 +90,13 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
|||
? `${data.verified} ${data.verified === 1 ? t('marketUi.reputation.tradeConfirmedOne') : t('marketUi.reputation.tradeConfirmedMany')} ${t('marketUi.reputation.with')} ${people} ${people === 1 ? t('marketUi.reputation.personOne') : t('marketUi.reputation.personMany')}`
|
||||
: t('marketUi.reputation.awaiting')}
|
||||
</p>
|
||||
|
||||
{data.recentVerified > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
<span className="font-bold text-[#ccff00]">{data.recentVerified}</span>{' '}
|
||||
{t('marketUi.reputation.recentConfirmed')}
|
||||
</p>
|
||||
)}
|
||||
{data.withPaymentProof > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useWallet } from '@/hooks/useWallet';
|
|||
import { useToast } from '@/hooks/useToast';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
|
||||
import type { WebLNProvider } from "@webbtc/webln-types";
|
||||
|
||||
|
|
@ -68,13 +69,7 @@ const AddWalletContent = forwardRef<HTMLDivElement, {
|
|||
});
|
||||
AddWalletContent.displayName = 'AddWalletContent';
|
||||
|
||||
// Recommended wallets for NWC
|
||||
const RECOMMENDED_WALLETS = [
|
||||
{ name: 'Alby', url: 'https://getalby.com', type: 'extension' },
|
||||
{ name: 'Minibits', url: 'https://www.minibits.cash', type: 'mobile' },
|
||||
{ name: 'Zeus', url: 'https://zeusln.com', type: 'mobile' },
|
||||
{ name: 'Coinos', url: 'https://coinos.io', type: 'web' },
|
||||
];
|
||||
|
||||
|
||||
// Extracted WalletContent to prevent re-renders
|
||||
const WalletContent = forwardRef<HTMLDivElement, {
|
||||
|
|
@ -86,6 +81,7 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
handleSetActive: (cs: string) => void;
|
||||
handleRemoveConnection: (cs: string) => void;
|
||||
setAddDialogOpen: (open: boolean) => void;
|
||||
onSelectCashu: () => void;
|
||||
}>(({
|
||||
webln,
|
||||
connections,
|
||||
|
|
@ -93,19 +89,42 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
activeConnection,
|
||||
handleSetActive,
|
||||
handleRemoveConnection,
|
||||
setAddDialogOpen
|
||||
setAddDialogOpen,
|
||||
onSelectCashu,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 px-0 sm:px-4 pb-4" ref={ref}>
|
||||
{/* NWC First - Primary Option */}
|
||||
{/* Native built-in wallet comes before external connection. */}
|
||||
{isNativePlatform() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectCashu}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-orange-500/30 bg-orange-500/[0.06] p-4 text-left transition-colors hover:bg-orange-500/[0.12]"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-orange-500/15">
|
||||
<Wallet className="h-5 w-5 text-orange-400" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-bold text-white">{t('walletUi.cashu.title')}</span>
|
||||
<span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">
|
||||
{t('walletUi.cashu.desc')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-gray-500">›</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* NWC - connect an existing wallet */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm sm:text-base">Nostr Wallet Connect</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('walletUi.modal.addWallet')}
|
||||
</Button>
|
||||
<h3 className="font-medium text-sm sm:text-base">{t('walletUi.modal.connectWallet')}</h3>
|
||||
{connections.length > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('walletUi.modal.addWallet')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connected Wallets List */}
|
||||
|
|
@ -166,7 +185,8 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* WebLN - Secondary Option */}
|
||||
{/* WebLN - show only when a provider is actually detected. */}
|
||||
{webln && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 sm:p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
|
|
@ -184,35 +204,19 @@ const WalletContent = forwardRef<HTMLDivElement, {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Wallets - Show when no wallet connected */}
|
||||
{!webln && connections.length === 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('walletUi.modal.getNwcFrom')}</h4>
|
||||
<div className="grid gap-2">
|
||||
{RECOMMENDED_WALLETS.map((wallet) => (
|
||||
<a
|
||||
key={wallet.name}
|
||||
href={wallet.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-2 border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium">{wallet.name}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{wallet.type}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{t('walletUi.modal.nwcHelp')}{' '}
|
||||
<a
|
||||
href="https://planb.academy/en/tutorials/wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-400 underline underline-offset-2 hover:text-orange-300"
|
||||
>
|
||||
{t('walletUi.page.learnPlanB')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -285,6 +289,12 @@ export function WalletModal({ children, className }: WalletModalProps) {
|
|||
handleSetActive,
|
||||
handleRemoveConnection,
|
||||
setAddDialogOpen,
|
||||
onSelectCashu: () => {
|
||||
setOpen(false);
|
||||
window.setTimeout(() => {
|
||||
document.getElementById('cashu-wallet')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 120);
|
||||
},
|
||||
};
|
||||
|
||||
const addWalletDialog = (
|
||||
|
|
|
|||
|
|
@ -180,7 +180,8 @@ export function AmberCallbackHandler() {
|
|||
case 'nip04_decrypt':
|
||||
case 'nip44_decrypt': {
|
||||
// Store encryption/decryption results
|
||||
sessionStorage.setItem('amber_crypto_result', result);
|
||||
// The result is delivered through the callback promise/flow; do not
|
||||
// leave private plaintext in browser storage.
|
||||
clearAmberPendingOperation();
|
||||
toast({
|
||||
title: t('settingsUi.amberCallback.operationCompleteTitle'),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
// Which login method panel is showing. The three method cards below switch it.
|
||||
const [loginTab, setLoginTab] = useState<'amber' | 'bunker' | 'nsec' | 'extension' | 'connect'>(
|
||||
isAndroid ? 'amber' : 'bunker',
|
||||
isAndroid ? 'amber' : 'connect',
|
||||
);
|
||||
|
||||
// Step 1: open Amber
|
||||
|
|
@ -212,7 +212,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
setConnectError('');
|
||||
setConnectState('waiting');
|
||||
const session = startNostrConnect(nostr, {
|
||||
appImage: 'https://skate-spots.org/images/icon-1024.png',
|
||||
appImage: 'https://skate-spots.org/images/brand-1024.png',
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
connectRef.current = session;
|
||||
|
|
@ -360,18 +360,19 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<Tabs value={loginTab} onValueChange={(v) => setLoginTab(v as typeof loginTab)} className="w-full">
|
||||
{/* Method cards — plain-language labels; protocol names move to a
|
||||
tiny hint so newcomers aren't met with "NIP-46" as the main word. */}
|
||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||
<div className="mb-3 grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ id: 'extension', Icon: Shield, label: t('settingsUi.loginDialog.extensionCard'), hint: 'NIP-07' },
|
||||
{ id: isAndroid ? 'amber' : 'bunker', Icon: isAndroid ? Smartphone : Cloud, label: t('settingsUi.loginDialog.signerCard'), hint: isAndroid ? 'NIP-55' : 'NIP-46' },
|
||||
{ id: 'connect', Icon: QrCode, label: t('settingsUi.loginDialog.signInWithPhone'), hint: 'NIP-46' },
|
||||
isAndroid
|
||||
? { id: 'amber', Icon: Smartphone, label: t('settingsUi.loginDialog.amberCard'), hint: 'NIP-55' }
|
||||
: { id: 'connect', Icon: QrCode, label: t('settingsUi.loginDialog.pairSignerCard'), hint: 'NIP-46' },
|
||||
] as const).map(({ id, Icon, label, hint }) => {
|
||||
const active = loginTab === id;
|
||||
const active = loginTab === id || (id === 'connect' && loginTab === 'bunker');
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setLoginTab(id)}
|
||||
onClick={() => setLoginTab(id as typeof loginTab)}
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col items-center gap-1 rounded-xl border p-2.5 text-center transition-colors',
|
||||
active
|
||||
|
|
@ -425,6 +426,13 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
>
|
||||
{t('settingsUi.loginDialog.noAmberGetIt')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoginTab('connect')}
|
||||
className="text-[11px] text-gray-500 underline underline-offset-2 hover:text-gray-300"
|
||||
>
|
||||
{t('settingsUi.loginDialog.useAnotherSigner')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -566,6 +574,14 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setLoginTab('bunker')}
|
||||
className='w-full text-[11px] text-gray-500 underline underline-offset-2 hover:text-gray-300'
|
||||
>
|
||||
{t('settingsUi.loginDialog.pasteBunker')}
|
||||
</button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -581,6 +597,13 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
|||
<Button className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase' disabled={isLoading || !bunkerUri.trim()} onClick={handleBunkerLogin}>
|
||||
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}{t('settingsUi.loginDialog.connect')}
|
||||
</Button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setLoginTab('connect')}
|
||||
className='w-full text-[11px] text-gray-500 underline underline-offset-2 hover:text-gray-300'
|
||||
>
|
||||
{t('settingsUi.loginDialog.backToQR')}
|
||||
</button>
|
||||
{/* Instructions collapse: the default state is just input + Connect. */}
|
||||
<details>
|
||||
<summary className='cursor-pointer text-[11px] text-gray-500 hover:text-gray-300'>
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #000;
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ export function CordnNativePanel({
|
|||
const [deviceLinkOpen, setDeviceLinkOpen] = useState(false);
|
||||
const [deviceLinkCode, setDeviceLinkCode] = useState<string | null>(null);
|
||||
const [deviceLinkPending, setDeviceLinkPending] = useState(false);
|
||||
const [deviceLinkError, setDeviceLinkError] = useState<string | null>(null);
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { isMuted, toggle: toggleMute } = useDmMute();
|
||||
|
|
@ -548,12 +549,13 @@ export function CordnNativePanel({
|
|||
const openDeviceLink = useCallback(async () => {
|
||||
setDeviceLinkOpen(true);
|
||||
setDeviceLinkCode(null);
|
||||
setDeviceLinkError(null);
|
||||
setDeviceLinkPending(true);
|
||||
setNotice(null);
|
||||
const code = await native.createDeviceLink();
|
||||
if (!code) {
|
||||
setDeviceLinkPending(false);
|
||||
setNotice(t('cordnUi.deviceLinkFailed'));
|
||||
setDeviceLinkError(t('cordnUi.deviceLinkFailed'));
|
||||
return;
|
||||
}
|
||||
setDeviceLinkCode(code);
|
||||
|
|
@ -814,6 +816,11 @@ export function CordnNativePanel({
|
|||
</div>
|
||||
</div>
|
||||
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
|
||||
{native.group && native.lastCheckedAt && (
|
||||
<p className="pt-0.5 text-[10px] text-gray-600">
|
||||
{t('cordnUi.lastChecked')} {formatDistanceToNow(new Date(native.lastCheckedAt), { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
{!isCommunity && (
|
||||
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
|
||||
|
|
@ -870,14 +877,29 @@ export function CordnNativePanel({
|
|||
</p>
|
||||
|
||||
<ol className="mx-auto max-w-sm space-y-2 text-left">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step} className="flex items-start gap-2 text-xs text-gray-400">
|
||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-[10px] font-bold text-teal-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
{steps.map((step, index) => {
|
||||
// Members see the setup move visibly: device key prepared →
|
||||
// request sent → waiting for an admin. Founders start at step 1.
|
||||
const stepDone = !canCreate && index === 0 && (awaiting || native.keyPackageReady);
|
||||
const stepWaiting = !canCreate && index === 1 && awaiting;
|
||||
return (
|
||||
<li
|
||||
key={step}
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-xs',
|
||||
stepDone || stepWaiting ? 'text-teal-200' : 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
|
||||
stepDone ? 'bg-teal-500/20 text-teal-300' : 'bg-teal-500/15 text-teal-300',
|
||||
)}>
|
||||
{stepDone ? <Check className="h-2.5 w-2.5" /> : stepWaiting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : index + 1}
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
|
||||
|
|
@ -1425,7 +1447,11 @@ export function CordnNativePanel({
|
|||
open={deviceLinkOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeviceLinkOpen(open);
|
||||
if (!open) setDeviceLinkCode(null);
|
||||
if (!open) {
|
||||
setDeviceLinkCode(null);
|
||||
setDeviceLinkPending(false);
|
||||
setDeviceLinkError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm border-white/10 bg-zinc-950">
|
||||
|
|
@ -1464,6 +1490,13 @@ export function CordnNativePanel({
|
|||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : deviceLinkError ? (
|
||||
<div className="space-y-3 py-4 text-center">
|
||||
<p className="text-xs text-red-300">{deviceLinkError}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => void openDeviceLink()}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> {t('common.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-xs text-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('common.loading')}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const AvatarImage = React.forwardRef<
|
|||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full object-cover", className)}
|
||||
referrerPolicy="no-referrer"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { useNostr } from '@nostrify/react';
|
|||
|
||||
/** The SkateSpots CVM server identity (see contextvm.org/s/<pubkey>). */
|
||||
export const CVM_SERVER_PUBKEY =
|
||||
'e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530';
|
||||
'940bf0e4fbc64b6c0f09d48c3264723c524a31b5ad0d1aaec361263731689d95';
|
||||
|
||||
/** The relay the CVM server announces on. */
|
||||
export const CVM_RELAY = 'wss://relay.contextvm.org';
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export interface CoordinatorHealth {
|
|||
name: string | null;
|
||||
error: string | null;
|
||||
checkedAt: number | null;
|
||||
/** Actual ContextVM RPC probe, not just presence. */
|
||||
rpcStatus: 'idle' | 'checking' | 'ok' | 'error';
|
||||
rpcLatencyMs: number | null;
|
||||
rpcError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,8 +69,52 @@ const INITIAL: CoordinatorHealth = {
|
|||
name: null,
|
||||
error: null,
|
||||
checkedAt: null,
|
||||
rpcStatus: 'idle',
|
||||
rpcLatencyMs: null,
|
||||
rpcError: null,
|
||||
};
|
||||
|
||||
function randomKeyHex(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function probeCoordinatorRpc(
|
||||
coordinator: CoordinatorConfig,
|
||||
relays: string[],
|
||||
): Promise<Pick<CoordinatorHealth, 'rpcStatus' | 'rpcLatencyMs' | 'rpcError'>> {
|
||||
let client: { listAvailableKeyPackages: () => Promise<unknown>; disconnect: () => Promise<void> } | null = null;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { CordnCoordinatorClient } = await import('@/lib/cordn/native/coordinator');
|
||||
client = new CordnCoordinatorClient({
|
||||
serverPubkey: coordinator.pubkey,
|
||||
relays,
|
||||
privateKeyHex: randomKeyHex(),
|
||||
});
|
||||
await Promise.race([
|
||||
client.listAvailableKeyPackages(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Coordinator RPC timed out.')), 7_000),
|
||||
),
|
||||
]);
|
||||
return { rpcStatus: 'ok', rpcLatencyMs: Date.now() - started, rpcError: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
rpcStatus: 'error',
|
||||
rpcLatencyMs: null,
|
||||
rpcError: e instanceof Error ? e.message : 'Coordinator RPC failed.',
|
||||
};
|
||||
} finally {
|
||||
if (client) await client.disconnect().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence probe for a coordinator.
|
||||
*
|
||||
|
|
@ -86,7 +134,16 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
|||
|
||||
const check = useCallback(async () => {
|
||||
if (!coordinator) return;
|
||||
setResult((r) => ({ ...r, status: 'checking', error: null }));
|
||||
setResult((r) => ({
|
||||
...r,
|
||||
status: 'checking',
|
||||
error: null,
|
||||
rpcStatus: 'checking',
|
||||
rpcLatencyMs: null,
|
||||
rpcError: null,
|
||||
}));
|
||||
|
||||
const rpc = await probeCoordinatorRpc(coordinator, relays);
|
||||
|
||||
try {
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
|
|
@ -113,6 +170,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
|||
name: null,
|
||||
error: null,
|
||||
checkedAt: Date.now(),
|
||||
...rpc,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -127,6 +185,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
|||
name: coordinatorNameFromEvent(newest),
|
||||
error: null,
|
||||
checkedAt: Date.now(),
|
||||
...rpc,
|
||||
});
|
||||
} catch (err) {
|
||||
setResult({
|
||||
|
|
@ -136,6 +195,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
|||
name: null,
|
||||
error: err instanceof Error ? err.message : 'Could not reach the relays',
|
||||
checkedAt: Date.now(),
|
||||
...rpc,
|
||||
});
|
||||
}
|
||||
}, [coordinator, relays, nostr]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useNostrLogin } from '@/lib/nostrLogin';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
|
|
|
|||