Compare commits
89 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5026b63323 | ||
|
|
601393ece3 | ||
|
|
ee6db357c2 | ||
|
|
cdf4a63809 | ||
|
|
23929653d3 | ||
|
|
344b41afc8 | ||
|
|
52a84479d4 | ||
|
|
f71f5eeef8 | ||
|
|
1f27311903 | ||
|
|
465683cf71 | ||
|
|
a4f72a3d05 | ||
|
|
c4a1671f81 | ||
|
|
20b2b0d676 | ||
|
|
95ff486038 | ||
|
|
7791cbe9fa | ||
|
|
26a8c5eb31 | ||
|
|
69a86493e8 | ||
|
|
f2787d79cf | ||
|
|
a1b069da13 | ||
|
|
c65e6effb5 | ||
|
|
b39c2ec386 | ||
|
|
22eb46c62b | ||
|
|
65a7f0bdbb | ||
|
|
f95adeecf1 | ||
|
|
f8a1054894 | ||
|
|
7958ca59bc | ||
|
|
68b77d6c14 | ||
|
|
8a347b4435 | ||
|
|
3d905af6ec | ||
|
|
1c361b8df5 | ||
|
|
20ff45089f | ||
|
|
e22e2ec85f | ||
|
|
dbc78a1760 | ||
|
|
0175645f3a | ||
|
|
008fb81f73 | ||
|
|
9838aa6d72 | ||
|
|
a9dffbf09d | ||
|
|
afd5a09acd | ||
|
|
e234253870 | ||
|
|
c66aeb526f | ||
|
|
267e93abc7 | ||
|
|
9dd2231332 | ||
|
|
1a88f8637d | ||
|
|
b95371a123 | ||
|
|
b76891798e | ||
|
|
7dc8c2c08b | ||
|
|
822cca23d7 | ||
|
|
c36bf2f029 | ||
|
|
443c7122d8 | ||
|
|
2d9758593a |
90 changed files with 3448 additions and 733 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -52,7 +52,8 @@ stats.json
|
||||||
|
|
||||||
# Local/generated assets & incidental lockfiles (do not commit)
|
# Local/generated assets & incidental lockfiles (do not commit)
|
||||||
/assets/
|
/assets/
|
||||||
/resources/
|
/resources/*
|
||||||
|
!/resources/splash.png
|
||||||
/bun.lock
|
/bun.lock
|
||||||
cvm-server/bun.lock
|
cvm-server/bun.lock
|
||||||
push-service/bun.lock
|
push-service/bun.lock
|
||||||
|
|
|
||||||
254
AGENTS.md
254
AGENTS.md
|
|
@ -2227,8 +2227,8 @@ session with **no other memory of this project**. So:
|
||||||
retries, then one full reload per session, so a chunk-load failure self-heals
|
retries, then one full reload per session, so a chunk-load failure self-heals
|
||||||
instead of showing the error boundary.
|
instead of showing the error boundary.
|
||||||
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
|
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
|
||||||
**150 tests**, build ok. The gate also type-checks the CVM server
|
**156 tests**, build ok. The gate also type-checks the CVM server
|
||||||
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.38**.
|
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.65**.
|
||||||
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
||||||
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||||||
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
||||||
|
|
@ -2415,6 +2415,95 @@ Rules:
|
||||||
- Any time the deploy output isn't verified by the smoke test, run
|
- Any time the deploy output isn't verified by the smoke test, run
|
||||||
`node scripts/smoke-test.mjs` manually before shipping.
|
`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.65). 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
|
### 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`.
|
- **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.
|
- ⚠️ **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.
|
||||||
|
|
@ -2688,6 +2777,135 @@ Rules:
|
||||||
hidden on `/become`, create/edit and `/supporters` forms; protobufjs
|
hidden on `/become`, create/edit and `/supporters` forms; protobufjs
|
||||||
override 7.6.5 (only remaining production advisory is write-only xlsx).
|
override 7.6.5 (only remaining production advisory is write-only xlsx).
|
||||||
APK sha256 `c5975f8b…`, versionCode 10738. Web forced live at `41f420a`.
|
APK sha256 `c5975f8b…`, versionCode 10738. Web forced live at `41f420a`.
|
||||||
|
- **1.7.39 (2026-10-30):** replaced write-only SheetJS `xlsx` with a small
|
||||||
|
local OOXML writer (`lib/xlsx.ts` + fflate); `npm audit --omit=dev` is now
|
||||||
|
**0 vulnerabilities**. Personal profiles have a **Lab services** strip
|
||||||
|
linking their Lab profiles. Added parser/XLSX tests (156 tests). APK
|
||||||
|
sha256 `1202aded…`, versionCode 10739. Web forced live at `6aba9b3`.
|
||||||
|
- **1.7.40 (2026-10-30):** native boot fix for the white/black reload loop —
|
||||||
|
`boot-guard.js` and `registerAppServiceWorker` clear stale
|
||||||
|
`skate-spots-shell-*` service workers/caches on the Capacitor
|
||||||
|
`https://localhost` origin before the bundle runs. APK sha256 `162488b5…`,
|
||||||
|
versionCode 10740. Web forced live at `44be542`.
|
||||||
|
- **1.7.41 (2026-10-30):** app-shell cache bumped to v3; `/boot-guard.js` is
|
||||||
|
never cached by the worker and web registration uses
|
||||||
|
`updateViaCache: 'none'`, so the cleanup cannot be trapped by an old
|
||||||
|
worker. APK sha256 `a32d56bb…`, versionCode 10741. Web forced live at
|
||||||
|
`c36bf2f`.
|
||||||
|
- **1.7.42 (2026-10-30):** black document background before CSS (no native
|
||||||
|
white flash) and a cache-busted boot-guard URL. APK sha256 `5baf7129…`,
|
||||||
|
versionCode 10742. Web forced live at `7dc8c2c`.
|
||||||
|
- **1.7.43 (2026-10-30):** boot-guard reload-loop breaker — three loads
|
||||||
|
within a minute clear the app-shell worker/cache and reload once into a
|
||||||
|
clean shell. APK sha256 `d25e28a4…`, versionCode 10743. Web forced live at
|
||||||
|
`1a88f86`.
|
||||||
|
- **1.7.44 (2026-10-30):** native service-worker cleanup fully
|
||||||
|
feature-checked/wrapped; it can no longer abort boot on Android WebViews
|
||||||
|
with a partial Service Worker API. APK sha256 `6193ab1b…`, versionCode
|
||||||
|
10744. Web forced live at `c66aeb5`.
|
||||||
|
- **1.7.45 (2026-10-30):** `MapPage` is eager instead of a lazy Suspense
|
||||||
|
boundary; slow/hung native chunk import could leave the black PageLoader
|
||||||
|
forever. APK sha256 `bd6a498b…`, versionCode 10745. Web forced live at
|
||||||
|
`afd5a09`.
|
||||||
|
- **1.7.46 (2026-10-30):** smoother open transition — logo starts near the
|
||||||
|
native splash centre and settles into the wordmark column; splash content
|
||||||
|
lifts/softens on exit while the app stays still. APK sha256 `89c07493…`,
|
||||||
|
versionCode 10746. Web forced live via manual `netlify deploy` at
|
||||||
|
`008fb81`.
|
||||||
|
- **1.7.47 (2026-10-30):** faster open (~0.7s typical, 0.9s cap); logo no
|
||||||
|
longer spins — it drops in once with a single spark burst — and the exit
|
||||||
|
is an aperture iris closing into the logo, with no dead black frame.
|
||||||
|
APK sha256 `d4b0dd74…`, versionCode 10747. Web forced live at `e22e2ec`.
|
||||||
|
- **1.7.48 (2026-10-30):** Amber decrypt loops now honour query
|
||||||
|
cancellation — leaving the DM inbox or closing the bell stops the next
|
||||||
|
Amber prompt instead of continuing through every remaining message.
|
||||||
|
The seal half of a DM decrypt is cached when a two-step decrypt is cut
|
||||||
|
short. APK sha256 `a74f21de…`, versionCode 10748. Web forced live at
|
||||||
|
`3d905af`.
|
||||||
|
- **1.7.49 (2026-10-30):** Cordn setup is single-flight, so a double-tap on
|
||||||
|
“Set up” cannot launch overlapping coordinator/Amber flows. Amber users
|
||||||
|
see the two real setup steps (device key → join request); each coordinator
|
||||||
|
request is a separate approval. APK sha256 `9dee0c4d…`, versionCode 10749.
|
||||||
|
Web forced live at `7958ca5`.
|
||||||
|
- **1.7.50 (2026-10-30):** performance/navigation/Amber polish. Web entry
|
||||||
|
shrank from ~863 KB to ~648 KB gzip: the map is web-lazy (native remains
|
||||||
|
eager via a Capacitor Vite alias), the Cashu/Zap dialog is loaded on first
|
||||||
|
tap, and primary routes prefetch on hover/touch. Page-level Suspense keeps
|
||||||
|
the header/bottom nav mounted with a delayed skeleton instead of a
|
||||||
|
full-screen black loader; the burger closes on route changes. Native Amber
|
||||||
|
calls are serialized/coalesced and decrypt results are memory-cached.
|
||||||
|
APK sha256 `d338c158…`, versionCode 10750. Web live at `65a7f0b`.
|
||||||
|
Production dependency audit: 0 vulnerabilities.
|
||||||
|
- **1.7.51 (2026-10-30):** profile/payment polish. The profile tip card no
|
||||||
|
longer renders empty when neither method exists; Lightning addresses accept
|
||||||
|
a pasted `lightning:` prefix and the profile row is tap-to-copy; CLINK codes
|
||||||
|
are canonicalised on save in profile/shop/crew/Lab forms; marketplace
|
||||||
|
listing detail surfaces a seller CLINK tip when their profile has one.
|
||||||
|
CLINK remains a wallet handoff, not an in-app rail. APK sha256 `ac433d91…`,
|
||||||
|
versionCode 10751. Web live at `c65e6ef`.
|
||||||
|
- **1.7.52 (2026-10-30):** compact tabbed `/supporters` page (Support /
|
||||||
|
Supporters / Community / Grants). The three funding rails are now a
|
||||||
|
one-at-a-time selector; impact, pay-for cards, partners/friends and the
|
||||||
|
grantmaker form keep all content with tighter mobile spacing. `/support`
|
||||||
|
redirects to `/supporters`. APK sha256 `acd581a2…`, versionCode 10752.
|
||||||
|
Web live at `69a8649`.
|
||||||
|
- **1.7.53 (2026-10-30):** Cordn/Staircase reliability. Newlay coordinator
|
||||||
|
budget raised (300/min, burst 120, concurrency 64), client polling now
|
||||||
|
backs off with jitter and polls hidden tabs half as often; relay health
|
||||||
|
alerts cover Cordn 429s/announce/TLS; Settings shows per-relay health pills;
|
||||||
|
Crew coordinator card adds a Staircase/Cordn QR. APK sha256 `f124669c…`,
|
||||||
|
versionCode 10753. Web live at `95ff486`.
|
||||||
|
- **1.7.54 (2026-10-30):** image-only Blossom quotas. Newlay per-object cap
|
||||||
|
10 MiB, signed per-npub quota proxy (50/250 MiB daily, admins 3×, per-IP
|
||||||
|
hourly backstop, 20 GB relay cap), app-side image/type/size guard and clear
|
||||||
|
errors; uploads use only our Blossom (mirrors are read-only fallbacks).
|
||||||
|
APK sha256 `f554d934…`, versionCode 10754. Web live at `465683c`.
|
||||||
|
- **1.7.55 (2026-10-30):** private read-only `/ops` status page for the two
|
||||||
|
relay admins, NIP-98-signed status endpoint fed by the host health timer;
|
||||||
|
admin link in Settings → Advanced. Added HSTS to relay/blossom and CAA
|
||||||
|
records limiting their certificates to Let’s Encrypt. APK sha256
|
||||||
|
`1142c425…`, versionCode 10755. Web live at `52a8447`.
|
||||||
|
- **1.7.56 (2026-10-30):** mobile nav simplified to five intents — Spots,
|
||||||
|
The Lab, Events, Crews, More. Profile/login stays in the header avatar;
|
||||||
|
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`.
|
||||||
|
|
||||||
### Relay archive (dataset backup)
|
### Relay archive (dataset backup)
|
||||||
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
||||||
|
|
@ -2748,6 +2966,11 @@ Rules:
|
||||||
and enables them. Logs: `journalctl --user -u skatespots-archive`.
|
and enables them. Logs: `journalctl --user -u skatespots-archive`.
|
||||||
- Commits touching only `scripts/`, `*.md`, `cvm-server/`, `skills/`, `.agents/`
|
- Commits touching only `scripts/`, `*.md`, `cvm-server/`, `skills/`, `.agents/`
|
||||||
are **skipped by the deploy timer** → no Netlify credits spent.
|
are **skipped by the deploy timer** → no Netlify credits spent.
|
||||||
|
- **XLSX writer without SheetJS (2026-10-30):** the only SheetJS use was the
|
||||||
|
deck-cost **export** (write-only). It is now `src/lib/xlsx.ts` using `fflate`
|
||||||
|
to emit a minimal OOXML workbook (inline strings, multi-sheet, safe names).
|
||||||
|
`xlsx` was removed; production `npm audit --omit=dev` is **0**. Keep the
|
||||||
|
writer write-only; never parse untrusted XLSX with it.
|
||||||
- **Dependency updates + vulnerability scan (2026-10-12):** `scripts/update-deps.sh`
|
- **Dependency updates + vulnerability scan (2026-10-12):** `scripts/update-deps.sh`
|
||||||
runs `npm outdated` + `npm audit` (dated report in `~/.local/state/skatespots-deps/`),
|
runs `npm outdated` + `npm audit` (dated report in `~/.local/state/skatespots-deps/`),
|
||||||
applies **non-breaking** updates only (`npm update` + `npm audit fix`, never
|
applies **non-breaking** updates only (`npm update` + `npm audit fix`, never
|
||||||
|
|
@ -2991,6 +3214,11 @@ The community relay moved off the hosted relay.tools instance
|
||||||
cosmetic; unauthenticated reads work. Config backup:
|
cosmetic; unauthenticated reads work. Config backup:
|
||||||
`~/newlay/deploy/docker/newlay.toml.bak-*`; restart with
|
`~/newlay/deploy/docker/newlay.toml.bak-*`; restart with
|
||||||
`sudo docker compose up -d --force-recreate` from `~/newlay`.
|
`sudo docker compose up -d --force-recreate` from `~/newlay`.
|
||||||
|
**2026-10-30:** after 3,767 `cordn rate limit` rejects in 24h (`msg_fetch_many`,
|
||||||
|
mostly the two admin pubkeys), raised `[cordn] rate_per_min` 120→300,
|
||||||
|
`rate_burst` 30→120 and `max_concurrent_requests` 32→64. Config backup
|
||||||
|
`newlay.toml.bak-cordn-20261030-102947`; restarted and verified coordinator
|
||||||
|
announce + NIP-11 200. Recheck the reject count before/after a Staircase soak.
|
||||||
⚠️ **Content can silently miss the canonical relay:** the app publishes to
|
⚠️ **Content can silently miss the canonical relay:** the app publishes to
|
||||||
`getWriteRelays()` and resolves on the **first** relay that accepts, so a
|
`getWriteRelays()` and resolves on the **first** relay that accepts, so a
|
||||||
rate-limited community-relay write can leave a spot only on ditto/damus (this
|
rate-limited community-relay write can leave a spot only on ditto/damus (this
|
||||||
|
|
@ -3019,7 +3247,9 @@ The community relay moved off the hosted relay.tools instance
|
||||||
dies, and posts to **ntfy.sh** when the problem set changes (deduped; also a
|
dies, and posts to **ntfy.sh** when the problem set changes (deduped; also a
|
||||||
"recovered" message). The topic is in `/home/ubuntu/skatespots-health.env`
|
"recovered" message). The topic is in `/home/ubuntu/skatespots-health.env`
|
||||||
(mode 600, not tracked) — subscribe to it in the ntfy app. Units tracked at
|
(mode 600, not tracked) — subscribe to it in the ntfy app. Units tracked at
|
||||||
`scripts/systemd/skatespots-health.{service,timer}`.
|
`scripts/systemd/skatespots-health.{service,timer}`. **2026-10-30:** the host
|
||||||
|
script also alerts on ≥40 Cordn rate-limit rejects/15m, a missing coordinator
|
||||||
|
announce/20m, and TLS expiry <14d; deployed to `/home/ubuntu/skatespots-health.sh`.
|
||||||
- **Media / Blossom (2026-10-12):** the relay also serves Newlay's Blossom at
|
- **Media / Blossom (2026-10-12):** the relay also serves Newlay's Blossom at
|
||||||
**`blossom.skate-spots.org`** (a Caddy site block → the same `127.0.0.1:7777`;
|
**`blossom.skate-spots.org`** (a Caddy site block → the same `127.0.0.1:7777`;
|
||||||
blobs stored at `/var/lib/newlay/data/blobs`, INSIDE the data volume so they
|
blobs stored at `/var/lib/newlay/data/blobs`, INSIDE the data volume so they
|
||||||
|
|
@ -3030,6 +3260,24 @@ The community relay moved off the hosted relay.tools instance
|
||||||
(`lib/imageFallback.ts`) includes it so reads fall back across all three.
|
(`lib/imageFallback.ts`) includes it so reads fall back across all three.
|
||||||
Verified end-to-end (signed PUT /upload → 201, GET /<sha> → 200). To fully
|
Verified end-to-end (signed PUT /upload → 201, GET /<sha> → 200). To fully
|
||||||
self-host media later, drop the two public hosts from both lists.
|
self-host media later, drop the two public hosts from both lists.
|
||||||
|
**2026-10-30 — image-only quota proxy:** `[blossom] max_size_bytes` lowered
|
||||||
|
to **10 MiB**. Caddy routes only `PUT /upload` through
|
||||||
|
`skatespots-quota.service` (`scripts/quota-proxy.mjs`, `127.0.0.1:8790`);
|
||||||
|
reads and all other Blossom routes still go straight to Newlay. The proxy
|
||||||
|
verifies the BUD-11 token, enforces image types and per-npub daily budgets
|
||||||
|
(50 uploads/250 MiB; admins 3×), a per-IP hourly backstop (100/500 MiB), and a
|
||||||
|
20 GB relay-wide cap. State is `/home/ubuntu/skatespots-quota/usage.json`;
|
||||||
|
the service is enabled, and the host health check checks its `/health`, the
|
||||||
|
service, and >18 GiB media. App uploads now use **only** our Blossom; public
|
||||||
|
mirrors are read-side fallbacks only, so a quota rejection cannot silently
|
||||||
|
land on a third party. No video upload support was added.
|
||||||
|
**2026-10-30 — transport/ops hardening:** HSTS is set on `relay.` and
|
||||||
|
`blossom.` (`max-age=31536000; includeSubDomains`); CAA records restrict
|
||||||
|
issuance for those two hosts to `letsencrypt.org`. The health timer writes an
|
||||||
|
aggregate `/home/ubuntu/skatespots-quota/status.json`; the quota proxy serves
|
||||||
|
it at `https://relay.skate-spots.org/ops` only for a NIP-98-signed request
|
||||||
|
from one of the two admin pubkeys. `/ops` is a read-only app page linked from
|
||||||
|
Settings → Advanced (AdminReviewCard). No restart/config actions are exposed.
|
||||||
- **Cordn coordinator (2026-10-12):** the relay also runs Newlay's **embedded Cordn
|
- **Cordn coordinator (2026-10-12):** the relay also runs Newlay's **embedded Cordn
|
||||||
coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`,
|
coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`,
|
||||||
`data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url =
|
`data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url =
|
||||||
|
|
|
||||||
97
distribution/README.md
Normal file
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.65):
|
||||||
|
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.65/SkateSpots-1.7.65.apk>
|
||||||
|
- SHA-256:
|
||||||
|
`736840855faefa48233f6b051e090b4ca7a51af94552d6afd8d960e68c355c82`
|
||||||
|
|
||||||
|
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
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
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
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
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
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
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
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.
|
||||||
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
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
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
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
1
fastlane/metadata/android/en-US/title.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
SkateSpots
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" style="background:#050505">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
@ -80,14 +80,14 @@
|
||||||
<link rel="preconnect" href="https://relay.skate-spots.org" crossorigin>
|
<link rel="preconnect" href="https://relay.skate-spots.org" crossorigin>
|
||||||
<link rel="preconnect" href="https://blossom.skate-spots.org" crossorigin>
|
<link rel="preconnect" href="https://blossom.skate-spots.org" crossorigin>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body style="background:#050505">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
BOOT CRASH GUARD (native-safe) — external so the web build can ship a
|
BOOT CRASH GUARD (native-safe) — external so the web build can ship a
|
||||||
strict CSP (script-src 'self') with no 'unsafe-inline'. See boot-guard.js.
|
strict CSP (script-src 'self') with no 'unsafe-inline'. See boot-guard.js.
|
||||||
-->
|
-->
|
||||||
<script src="/boot-guard.js"></script>
|
<script src="/boot-guard.js?v=20261030"></script>
|
||||||
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.40",
|
"version": "1.7.65",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.40",
|
"version": "1.7.65",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.40",
|
"version": "1.7.65",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.org",
|
"homepage": "https://skate-spots.org",
|
||||||
|
|
@ -21,11 +21,11 @@
|
||||||
"check:i18n": "node scripts/check-i18n.mjs",
|
"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!'",
|
"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",
|
"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: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-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:open": "npx cap open android",
|
||||||
"android:version": "node scripts/sync-android-version.mjs",
|
"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",
|
"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",
|
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
||||||
"translation:matrix": "node scripts/translation-matrix.mjs"
|
"translation:matrix": "node scripts/translation-matrix.mjs"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,51 @@
|
||||||
(function () {
|
(function () {
|
||||||
var shown = false;
|
var shown = false;
|
||||||
|
|
||||||
|
// Reload-loop breaker. A stale app-shell worker can reload before the boot
|
||||||
|
// guard's 12s error panel is ever shown. After three loads inside one minute,
|
||||||
|
// clear the shell cache/worker once and reload into a clean shell.
|
||||||
|
try {
|
||||||
|
var ATTEMPT_KEY = 'skatespots:boot-attempts';
|
||||||
|
var now = Date.now();
|
||||||
|
var attempts = { count: 0, at: now };
|
||||||
|
try {
|
||||||
|
var stored = JSON.parse(sessionStorage.getItem(ATTEMPT_KEY) || 'null');
|
||||||
|
if (stored && typeof stored.at === 'number' && now - stored.at < 60000) {
|
||||||
|
attempts.count = Number(stored.count) || 0;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
attempts.count += 1;
|
||||||
|
attempts.at = now;
|
||||||
|
try { sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify(attempts)); } catch (e) {}
|
||||||
|
if (attempts.count >= 3) {
|
||||||
|
try { sessionStorage.removeItem(ATTEMPT_KEY); } catch (e) {}
|
||||||
|
var finished = false;
|
||||||
|
var finish = function () {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
var jobs = [];
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
jobs.push(navigator.serviceWorker.getRegistrations().then(function (regs) {
|
||||||
|
regs.forEach(function (reg) { try { reg.unregister(); } catch (e) {} });
|
||||||
|
}).catch(function () {}));
|
||||||
|
}
|
||||||
|
if (window.caches) {
|
||||||
|
jobs.push(window.caches.keys().then(function (keys) {
|
||||||
|
return Promise.all(keys.filter(function (key) { return key.indexOf('skate-spots-shell-') === 0; })
|
||||||
|
.map(function (key) { return window.caches.delete(key).catch(function () {}); }));
|
||||||
|
}).catch(function () {}));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (jobs.length) Promise.all(jobs).then(finish).catch(finish);
|
||||||
|
else finish();
|
||||||
|
setTimeout(finish, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* best effort */ }
|
||||||
|
|
||||||
// Native WebViews can keep a service worker from an older build forever.
|
// Native WebViews can keep a service worker from an older build forever.
|
||||||
// That worker can serve a stale index.html whose hashed chunks no longer
|
// That worker can serve a stale index.html whose hashed chunks no longer
|
||||||
// exist, producing a white/black reload loop. The native shell is served from
|
// exist, producing a white/black reload loop. The native shell is served from
|
||||||
|
|
|
||||||
127
public/download/index.html
Normal file
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.65/SkateSpots-1.7.65.apk"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<span>Download SkateSpots-1.7.65.apk · 56 MB</span>
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<span>Requires Android 7.0 or newer.</span>
|
||||||
|
<span>SHA-256: <code>736840855faefa48233f6b051e090b4ca7a51af94552d6afd8d960e68c355c82</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/opensats.ico
Normal file
BIN
public/images/opensats.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
|
|
@ -10,7 +10,7 @@
|
||||||
* navigations are network-first with an index fallback. No API, DM or model
|
* navigations are network-first with an index fallback. No API, DM or model
|
||||||
* request is ever stored; only same-origin static bytes.
|
* request is ever stored; only same-origin static bytes.
|
||||||
*/
|
*/
|
||||||
const SHELL_CACHE = 'skate-spots-shell-v2';
|
const SHELL_CACHE = 'skate-spots-shell-v3';
|
||||||
const SHELL_ASSETS = ['/', '/index.html'];
|
const SHELL_ASSETS = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
|
@ -39,6 +39,12 @@ self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin) return;
|
if (url.origin !== self.location.origin) return;
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/.netlify/')) return;
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/.netlify/')) return;
|
||||||
|
// Never cache the boot guard: an old cached copy must not be able to trap
|
||||||
|
// the app in its own stale-worker cleanup path.
|
||||||
|
if (url.pathname === '/boot-guard.js') {
|
||||||
|
event.respondWith(fetch(request).catch(() => caches.match(request).then((cached) => cached || Response.error())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,294 @@
|
||||||
|
🔎 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,
|
||||||
|
wallet, settings, logout) — the avatar itself is no longer a menu trigger.
|
||||||
|
- Removed the redundant “IN” label so the brand and header controls fit on
|
||||||
|
narrow phones.
|
||||||
|
- The SkateSpots brand mark still goes to the map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🧭 Simpler mobile navigation
|
||||||
|
- The bottom bar is now exactly five intents: Spots, The Lab, Events, Crews
|
||||||
|
and More. Profile/login moved to the header avatar, where it already lived.
|
||||||
|
- Mints and every other secondary section stay in the More menu — nothing was
|
||||||
|
removed, only moved out of the way.
|
||||||
|
- The nav customiser now edits three middle slots and previews the fixed More
|
||||||
|
button. Existing four-slot layouts are normalised to three on read.
|
||||||
|
- Desktop defaults now show Community instead of Mints as a primary tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🔐 Private operator status + transport hardening
|
||||||
|
- New admin-only `/ops` page shows relay/coordinator health, Cordn 429s,
|
||||||
|
services, disk/memory, media usage and TLS expiry.
|
||||||
|
- The status endpoint requires a NIP-98 signature from one of the two relay
|
||||||
|
admin npubs. Nothing is public; the page has no restart/config actions.
|
||||||
|
- The host health timer now writes an aggregate status snapshot for that
|
||||||
|
endpoint every five minutes.
|
||||||
|
- Added HSTS to `relay.skate-spots.org` and `blossom.skate-spots.org`.
|
||||||
|
- Added CAA records restricting certificate issuance for those two hosts to
|
||||||
|
Let's Encrypt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🖼️ Image uploads are now bounded by npub, not just IP
|
||||||
|
- Blossom uploads are image-only: JPEG, PNG, WebP, AVIF, HEIC or GIF, max 10 MB.
|
||||||
|
No video uploads were added.
|
||||||
|
- A signed quota proxy now verifies the BUD-11 upload token, then enforces:
|
||||||
|
• 50 images / 250 MB per npub per day
|
||||||
|
• 3× that budget for the two relay admins
|
||||||
|
• a generous 100 images / 500 MB per IP per hour backstop
|
||||||
|
• a 20 GB relay-wide media budget with health alerts
|
||||||
|
- Uploads go through our own Blossom only. Public mirrors remain read-side
|
||||||
|
fallbacks for existing images, but can no longer silently bypass the quota.
|
||||||
|
- Clear size/type/daily-limit errors replace raw Blossom responses.
|
||||||
|
- Newlay’s own per-object cap is now 10 MB as a second line of defence.
|
||||||
|
|
||||||
|
The private read-only admin dashboard is next; this release is the quota layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🛰️ Cordn / Staircase reliability pass
|
||||||
|
- Raised the embedded coordinator’s request budget (120/min → 300/min,
|
||||||
|
burst 30 → 120) and concurrency (32 → 64) after finding 3,767 rate-limit
|
||||||
|
rejections in one day.
|
||||||
|
- Cordn polling now backs off exponentially with jitter after failures, polls
|
||||||
|
hidden tabs half as often, and clears stale error state on recovery.
|
||||||
|
- The relay health check now alerts on Cordn rate-limit bursts, a missing
|
||||||
|
coordinator announcement, and TLS expiry — not just a dead relay.
|
||||||
|
- Settings → Coordinator shows live health dots for each configured relay.
|
||||||
|
- Crew coordination card adds a scan-to-add QR for Staircase/Cordn clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🛹 Support page: compact, tabbed, easier to scan
|
||||||
|
- The supporters page is now split into four tabs — Support, Supporters,
|
||||||
|
Community, Grants — instead of one very long scroll.
|
||||||
|
- The three funding rails are a compact selector: only the chosen rail opens,
|
||||||
|
while “what your support pays for” stays visible underneath.
|
||||||
|
- Hero, section headers, impact stats, cards and spacing are tighter on mobile
|
||||||
|
while keeping all grantmaker transparency and everyone’s credit.
|
||||||
|
- `/support` now redirects to `/supporters`, so the page is easy to find.
|
||||||
|
- Donate page keeps its focused single-page layout and still uses the same rail
|
||||||
|
cards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
💸 Profile payments: CLINK + Lightning, properly separated
|
||||||
|
- The profile tip card no longer appears as an empty box when a skater has no
|
||||||
|
payment method. Both can now coexist cleanly:
|
||||||
|
• Lightning address (`you@yourdomain.com`) powers Zaps and direct sats.
|
||||||
|
• CLINK (`noffer1…`) powers the self-custodial “Tip in Bitcoin” handoff.
|
||||||
|
- Lightning addresses are normalised (accept a pasted `lightning:` prefix) and
|
||||||
|
the profile Lightning row is now tap-to-copy.
|
||||||
|
- CLINK codes are normalised on save everywhere (profile, shops, crews, Lab),
|
||||||
|
so a pasted `nostr:noffer1…` is stored as the canonical raw code.
|
||||||
|
- Marketplace listing detail now surfaces the seller’s CLINK tip button when
|
||||||
|
their profile has a valid CLINK offer.
|
||||||
|
- CLINK remains an explicit handoff to a CLINK-compatible wallet; it is not
|
||||||
|
presented as an in-app payment rail. Lightning remains the in-app rail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
⚡ Faster shell, smoother menus, safer Amber
|
||||||
|
- The web entry bundle is ~25% smaller after moving the map to a web-only lazy
|
||||||
|
route and loading the Cashu/Zap dialog on first tap. Android keeps the map
|
||||||
|
eager (static) so the native boot path is unchanged.
|
||||||
|
- Page navigation keeps the header and bottom nav mounted. A short delayed
|
||||||
|
skeleton fades in under the chrome instead of a full-screen black spinner,
|
||||||
|
and primary menu destinations prefetch on hover/touch.
|
||||||
|
- The mobile burger menu now closes on any route change (back, notifications,
|
||||||
|
global search), and nav links expose aria-current.
|
||||||
|
- Amber native calls are serialized and coalesced by request. Identical
|
||||||
|
concurrent decrypts/signs share one result, decrypts are memory-cached, and
|
||||||
|
overlapping intents can no longer race the 60s native timeout.
|
||||||
|
- Production dependency audit: 0 vulnerabilities. No risky major upgrades.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🔐 Community chat setup: no overlapping Amber requests
|
||||||
|
- Cordn setup now runs one coordinator operation at a time. A double-tap on
|
||||||
|
“Set up” can no longer start two publish/join flows and leave one Amber
|
||||||
|
request orphaned.
|
||||||
|
- The setup panel now shows the two real steps for Amber users:
|
||||||
|
Set up this device → Request join. Those are two separate coordinator
|
||||||
|
signatures, so seeing two prompts is expected; the first publishes this
|
||||||
|
device’s key package, the second asks the room to approve it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🛑 Amber: no more popups after you navigate away
|
||||||
|
- The DM inbox and Cordn notification readers decrypt message-by-message. When
|
||||||
|
you left the screen while Amber was open, the query was cancelled but the loop
|
||||||
|
kept launching Amber for every remaining message — the app looked possessed.
|
||||||
|
- Every decrypt loop now checks the query's abort signal before the next message
|
||||||
|
and stops immediately. Closing the notification bell also stops its decrypt
|
||||||
|
loop.
|
||||||
|
- The first half of a two-step DM decrypt is cached, so if you leave mid-request
|
||||||
|
the next visit only asks once for that message instead of asking twice again.
|
||||||
|
- Cordn's crew-message fetch now honours the same cancellation rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
⚡ Faster open, no spinning logo
|
||||||
|
- The cold-start splash now clears in about 0.7s instead of ~1.7s: no artificial
|
||||||
|
hold, no long relay wait, and a hard 0.9s cap.
|
||||||
|
- The wheel mark no longer spins. It drops in once, throws a single short spark
|
||||||
|
burst, then stays still.
|
||||||
|
- The exit is an aperture-style iris that closes into the logo, revealing the
|
||||||
|
app from the edges without moving the map. Content stays visible through the
|
||||||
|
transition, so there is no dead black frame.
|
||||||
|
- Reduced-motion users get the same faster timing with a plain fade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
✨ Smoother app-open transition
|
||||||
|
- The logo now starts near the native splash’s centred position and settles into
|
||||||
|
the wordmark/progress column, so the native → web handoff has no jump.
|
||||||
|
- On exit the splash content lifts and softens while the app revealed beneath
|
||||||
|
stays still; no map nudge or zoom flash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🚨 Native boot: eager map + safe cleanup
|
||||||
|
- The home map is now imported eagerly instead of through a lazy Suspense
|
||||||
|
boundary. A slow/hung native chunk import could leave the app on the black
|
||||||
|
PageLoader forever; the header and map now start immediately.
|
||||||
|
- Native service-worker cleanup is feature-checked and can no longer throw
|
||||||
|
before React mounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🚨 Critical native boot fix
|
||||||
|
- Native service-worker cleanup is now fully feature-checked and wrapped.
|
||||||
|
Some Android WebViews expose a partial Service Worker API; the cleanup could
|
||||||
|
throw before React mounted, leaving the splash/logo then a black screen.
|
||||||
|
- Boot now continues even when that cleanup is unsupported. The cleanup remains
|
||||||
|
best-effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🚨 Boot-loop breaker
|
||||||
|
- If the app reloads three times within a minute, the boot guard now clears the
|
||||||
|
old app-shell worker/caches once and reloads into a clean shell before the
|
||||||
|
error panel can be trapped. Normal opens and reloads are unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🩹 Remove the white flash from native boot
|
||||||
|
- The document background is black before CSS loads, so a native launch can no
|
||||||
|
longer flash white between the splash and the app shell.
|
||||||
|
- `boot-guard.js` uses a cache-busting query and is never cached by the app-shell
|
||||||
|
worker, so the old-worker cleanup is always the new code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🩹 Stronger native boot recovery
|
||||||
|
- Bumped the app-shell worker cache to v3; old shell caches are deleted on
|
||||||
|
activate.
|
||||||
|
- `boot-guard.js` is no longer cached by the service worker, so a stale worker
|
||||||
|
can never trap the app in its own cleanup path.
|
||||||
|
- Native WebViews unregister old app-shell workers and clear their caches before
|
||||||
|
the bundle runs. Web registrations use updateViaCache: none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
🩹 Fix native white/black reload loop
|
🩹 Fix native white/black reload loop
|
||||||
- Native installs now clear any old app-shell service worker and
|
- Native installs now clear any old app-shell service worker and
|
||||||
`skate-spots-shell-*` cache from `https://localhost` before the bundle runs.
|
`skate-spots-shell-*` cache from `https://localhost` before the bundle runs.
|
||||||
|
|
|
||||||
BIN
resources/splash.png
Normal file
BIN
resources/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -35,18 +35,92 @@ svc skatespots-relay.service
|
||||||
svc skatespots-ai.service
|
svc skatespots-ai.service
|
||||||
svc skatespots-push.service
|
svc skatespots-push.service
|
||||||
svc skatespots-cvm.service
|
svc skatespots-cvm.service
|
||||||
|
svc skatespots-quota.service
|
||||||
svc caddy.service
|
svc caddy.service
|
||||||
|
|
||||||
[ "$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)" != "200" ] \
|
[ "$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)" != "200" ] \
|
||||||
&& ISSUES+=("relay public TLS not answering 200")
|
&& ISSUES+=("relay public TLS not answering 200")
|
||||||
[ "$(code https://push.skate-spots.org/health)" != "200" ] \
|
[ "$(code https://push.skate-spots.org/health)" != "200" ] \
|
||||||
&& ISSUES+=("push /health not 200")
|
&& ISSUES+=("push /health not 200")
|
||||||
|
[ "$(code http://127.0.0.1:8790/health)" != "200" ] \
|
||||||
|
&& ISSUES+=("blossom quota proxy /health not 200")
|
||||||
|
|
||||||
disk=$(df --output=pcent / | tail -1 | tr -d ' %')
|
disk=$(df --output=pcent / | tail -1 | tr -d ' %')
|
||||||
[ "${disk:-0}" -ge 85 ] && ISSUES+=("disk ${disk}% used")
|
[ "${disk:-0}" -ge 85 ] && ISSUES+=("disk ${disk}% used")
|
||||||
avail=$(free -m | awk '/Mem:/{print $7}')
|
avail=$(free -m | awk '/Mem:/{print $7}')
|
||||||
[ "${avail:-0}" -lt 300 ] && ISSUES+=("low memory: ${avail} MiB available")
|
[ "${avail:-0}" -lt 300 ] && ISSUES+=("low memory: ${avail} MiB available")
|
||||||
|
|
||||||
|
# Cordn/Staircase RPC health. Newlay logs one warning per rejected request, so
|
||||||
|
# a burst is easy to miss in routine log noise. Surface it as an operational
|
||||||
|
# problem before users experience a stuck private chat.
|
||||||
|
cordn_rejects=$(docker logs --since 15m newlay-newlay-1 2>&1 | grep -c 'cordn rate limit' || true)
|
||||||
|
[ "${cordn_rejects:-0}" -ge 40 ] && ISSUES+=("cordn rate limits: ${cordn_rejects} rejects in 15m")
|
||||||
|
|
||||||
|
# `grep -q` + pipefail makes `docker logs` exit 141 on SIGPIPE, which used to
|
||||||
|
# make this check fire even when an announcement was present. Count instead.
|
||||||
|
cordn_announce=$(docker logs --since 20m newlay-newlay-1 2>&1 | grep -c 'cordn announce' || true)
|
||||||
|
[ "${cordn_announce:-0}" -eq 0 ] && ISSUES+=("cordn coordinator has not announced in 20m")
|
||||||
|
|
||||||
|
# Blossom media budget (usage is persisted by the quota proxy).
|
||||||
|
quota_state=/home/ubuntu/skatespots-quota/usage.json
|
||||||
|
if [ -f "$quota_state" ]; then
|
||||||
|
total_bytes=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("totalBytes",0))' "$quota_state" 2>/dev/null || echo 0)
|
||||||
|
[ "${total_bytes:-0}" -ge 19327352832 ] && ISSUES+=("blossom media >18 GiB")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# TLS expiry (Caddy auto-renews; this catches a broken renewal early).
|
||||||
|
expiry=$(echo | openssl s_client -servername relay.skate-spots.org -connect relay.skate-spots.org:443 2>/dev/null \
|
||||||
|
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||||
|
if [ -n "$expiry" ]; then
|
||||||
|
days=$(( ( $(date -d "$expiry" +%s) - $(date +%s) ) / 86400 ))
|
||||||
|
[ "$days" -lt 14 ] && ISSUES+=("relay TLS expires in ${days}d")
|
||||||
|
else
|
||||||
|
ISSUES+=("could not read relay TLS certificate")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Machine-readable snapshot for the signed admin dashboard. Aggregate numbers
|
||||||
|
# only: no IPs, pubkeys, paths or secrets.
|
||||||
|
local_relay=$(code -H 'Accept: application/nostr+json' http://127.0.0.1:7777/)
|
||||||
|
public_relay=$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)
|
||||||
|
push_status=$(code https://push.skate-spots.org/health)
|
||||||
|
quota_status=$(code http://127.0.0.1:8790/health)
|
||||||
|
version=$(curl -s -m 8 -H 'Accept: application/nostr+json' https://relay.skate-spots.org/ \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("version",""))' 2>/dev/null || true)
|
||||||
|
svc_on() { systemctl is-active --quiet "$1" && echo true || echo false; }
|
||||||
|
STATUS_DISK="${disk:-0}" STATUS_AVAIL="${avail:-0}" STATUS_MEDIA="${total_bytes:-0}" \
|
||||||
|
STATUS_CORDN_REJECTS="${cordn_rejects:-0}" STATUS_CORDN_ANNOUNCE="${cordn_announce:-0}" \
|
||||||
|
STATUS_TLS_DAYS="${days:-0}" STATUS_LOCAL="${local_relay:-000}" STATUS_PUBLIC="${public_relay:-000}" \
|
||||||
|
STATUS_PUSH="${push_status:-000}" STATUS_QUOTA="${quota_status:-000}" STATUS_VERSION="$version" \
|
||||||
|
STATUS_SERVICES="$(printf 'relay=%s\nai=%s\npush=%s\ncvm=%s\nquota=%s\ncaddy=%s\n' \
|
||||||
|
"$(svc_on skatespots-relay.service)" "$(svc_on skatespots-ai.service)" \
|
||||||
|
"$(svc_on skatespots-push.service)" "$(svc_on skatespots-cvm.service)" \
|
||||||
|
"$(svc_on skatespots-quota.service)" "$(svc_on caddy.service)")" \
|
||||||
|
STATUS_ISSUES="$(printf '%s\n' "${ISSUES[@]}")" \
|
||||||
|
python3 - <<'PY'
|
||||||
|
import datetime, json, os
|
||||||
|
services = {}
|
||||||
|
for line in os.environ.get('STATUS_SERVICES','').splitlines():
|
||||||
|
if '=' in line:
|
||||||
|
k,v = line.split('=',1); services[k] = v == 'true'
|
||||||
|
issues = [x for x in os.environ.get('STATUS_ISSUES','').splitlines() if x]
|
||||||
|
payload = {
|
||||||
|
'updatedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
'relay': {'local': os.environ.get('STATUS_LOCAL'), 'public': os.environ.get('STATUS_PUBLIC')},
|
||||||
|
'version': os.environ.get('STATUS_VERSION',''),
|
||||||
|
'cordn': {'announces20m': int(os.environ.get('STATUS_CORDN_ANNOUNCE','0') or 0),
|
||||||
|
'rejects15m': int(os.environ.get('STATUS_CORDN_REJECTS','0') or 0)},
|
||||||
|
'storage': {'diskPct': int(os.environ.get('STATUS_DISK','0') or 0),
|
||||||
|
'memAvailMiB': int(os.environ.get('STATUS_AVAIL','0') or 0),
|
||||||
|
'mediaBytes': int(os.environ.get('STATUS_MEDIA','0') or 0)},
|
||||||
|
'tls': {'days': int(os.environ.get('STATUS_TLS_DAYS','0') or 0)},
|
||||||
|
'services': services,
|
||||||
|
'issues': issues,
|
||||||
|
}
|
||||||
|
path = '/home/ubuntu/skatespots-quota/status.json'
|
||||||
|
with open(path,'w') as f: json.dump(payload,f)
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
PY
|
||||||
|
|
||||||
sig=""
|
sig=""
|
||||||
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
|
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
|
||||||
prev=""
|
prev=""
|
||||||
|
|
|
||||||
41
scripts/inject-backup-policy.mjs
Normal file
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).');
|
||||||
|
}
|
||||||
108
scripts/publish-forgejo.sh
Executable file
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"
|
||||||
220
scripts/quota-proxy.mjs
Normal file
220
scripts/quota-proxy.mjs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* SkateSpots Blossom upload quota proxy.
|
||||||
|
*
|
||||||
|
* Caddy sends ONLY `PUT /upload` here; reads and all other Blossom routes go
|
||||||
|
* straight to Newlay. The proxy verifies the signed BUD-11 (kind 24242) token,
|
||||||
|
* enforces image-only + size, per-npub daily budgets and a generous per-IP
|
||||||
|
* backstop, then forwards the accepted bytes to Newlay.
|
||||||
|
*
|
||||||
|
* Primary quota key = npub (portable, fair across devices/IPs).
|
||||||
|
* Secondary key = client IP (anti-abuse / key-rotation backstop).
|
||||||
|
* Admin pubkeys get a higher npub budget, never an unlimited bypass.
|
||||||
|
*
|
||||||
|
* This is intentionally dependency-light: it reuses the nostr-tools already
|
||||||
|
* installed by the push service, so the relay host needs no new npm install.
|
||||||
|
*
|
||||||
|
* Environment:
|
||||||
|
* PORT=8790
|
||||||
|
* NOSTR_TOOLS_ROOT=/home/ubuntu/skatespots-push/
|
||||||
|
* QUOTA_STATE=/home/ubuntu/skatespots-quota/usage.json
|
||||||
|
* ADMIN_PUBKEYS=20e7...,caba...
|
||||||
|
* MAX_IMAGE_BYTES=10485760
|
||||||
|
* DAILY_BYTES=262144000 (250 MiB)
|
||||||
|
* DAILY_UPLOADS=50
|
||||||
|
* ADMIN_MULTIPLIER=3
|
||||||
|
* IP_HOURLY_BYTES=524288000 (500 MiB)
|
||||||
|
* IP_HOURLY_UPLOADS=100
|
||||||
|
* TOTAL_BYTES=21474836480 (20 GiB)
|
||||||
|
*/
|
||||||
|
import http from 'node:http';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(process.env.NOSTR_TOOLS_ROOT || '/home/ubuntu/skatespots-push/');
|
||||||
|
const { verifyEvent } = require('nostr-tools');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 8790);
|
||||||
|
const UPSTREAM_PORT = Number(process.env.UPSTREAM_PORT || 7777);
|
||||||
|
const MAX_IMAGE_BYTES = Number(process.env.MAX_IMAGE_BYTES || 10 * 1024 * 1024);
|
||||||
|
const DAILY_BYTES = Number(process.env.DAILY_BYTES || 250 * 1024 * 1024);
|
||||||
|
const DAILY_UPLOADS = Number(process.env.DAILY_UPLOADS || 50);
|
||||||
|
const ADMIN_MULTIPLIER = Number(process.env.ADMIN_MULTIPLIER || 3);
|
||||||
|
const IP_HOURLY_BYTES = Number(process.env.IP_HOURLY_BYTES || 500 * 1024 * 1024);
|
||||||
|
const IP_HOURLY_UPLOADS = Number(process.env.IP_HOURLY_UPLOADS || 100);
|
||||||
|
const TOTAL_BYTES = Number(process.env.TOTAL_BYTES || 20 * 1024 * 1024 * 1024);
|
||||||
|
const STATE_PATH = process.env.QUOTA_STATE || '/home/ubuntu/skatespots-quota/usage.json';
|
||||||
|
const STATUS_PATH = process.env.STATUS_PATH || '/home/ubuntu/skatespots-quota/status.json';
|
||||||
|
const ADMIN_PUBKEYS = new Set(
|
||||||
|
String(process.env.ADMIN_PUBKEYS || '')
|
||||||
|
.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const IMAGE_TYPES = new Set([
|
||||||
|
'image/jpeg', 'image/png', 'image/webp', 'image/avif',
|
||||||
|
'image/heic', 'image/heif', 'image/gif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
mkdirSync(dirname(STATE_PATH), { recursive: true });
|
||||||
|
|
||||||
|
let state = loadState();
|
||||||
|
function loadState() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return { day: '', hour: '', totalBytes: 0, users: {}, ips: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveState() {
|
||||||
|
const tmp = `${STATE_PATH}.tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(state));
|
||||||
|
renameSync(tmp, STATE_PATH);
|
||||||
|
}
|
||||||
|
function rollWindows() {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.toISOString().slice(0, 10);
|
||||||
|
const hour = now.toISOString().slice(0, 13);
|
||||||
|
if (state.day !== day) { state.day = day; state.users = {}; }
|
||||||
|
if (state.hour !== hour) { state.hour = hour; state.ips = {}; }
|
||||||
|
}
|
||||||
|
function clientIp(req) {
|
||||||
|
const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||||
|
return xff || req.socket.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
function json(res, status, body) {
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
res.writeHead(status, {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': Buffer.byteLength(payload),
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
});
|
||||||
|
res.end(payload);
|
||||||
|
}
|
||||||
|
function readBody(req, limit) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const declared = Number(req.headers['content-length'] || 0);
|
||||||
|
if (declared > limit) { reject(Object.assign(new Error('too large'), { code: 413 })); return; }
|
||||||
|
const chunks = [];
|
||||||
|
let total = 0;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > limit) {
|
||||||
|
reject(Object.assign(new Error('too large'), { code: 413 }));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function decodeToken(header) {
|
||||||
|
const m = /^Nostr\s+(.+)$/i.exec(String(header || ''));
|
||||||
|
if (!m) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(m[1], 'base64').toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tag(event, name) {
|
||||||
|
return event.tags?.find(([n]) => n === name)?.[1];
|
||||||
|
}
|
||||||
|
function checkToken(event, body, contentType) {
|
||||||
|
if (!event || event.kind !== 24242) return 'Invalid upload authorization.';
|
||||||
|
if (!verifyEvent(event)) return 'Invalid upload signature.';
|
||||||
|
if (tag(event, 't') !== 'upload') return 'Invalid upload token type.';
|
||||||
|
const exp = Number(tag(event, 'expiration') || 0);
|
||||||
|
if (!exp || exp < Math.floor(Date.now() / 1000)) return 'Upload authorization expired.';
|
||||||
|
if (!contentType.startsWith('image/')) return 'Only images can be uploaded.';
|
||||||
|
const x = String(tag(event, 'x') || '').toLowerCase();
|
||||||
|
const size = Number(tag(event, 'size') || 0);
|
||||||
|
const digest = createHash('sha256').update(body).digest('hex');
|
||||||
|
if (!x || x !== digest || size !== body.length) return 'Upload token does not match the image.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function checkQuota(pubkey, bytes, ip) {
|
||||||
|
const admin = ADMIN_PUBKEYS.has(pubkey);
|
||||||
|
const multiplier = admin ? ADMIN_MULTIPLIER : 1;
|
||||||
|
const user = state.users[pubkey] || (state.users[pubkey] = { uploads: 0, bytes: 0 });
|
||||||
|
if (user.uploads + 1 > DAILY_UPLOADS * multiplier) return 'Daily upload count reached. Try again tomorrow.';
|
||||||
|
if (user.bytes + bytes > DAILY_BYTES * multiplier) return 'Daily upload size reached. Try again tomorrow.';
|
||||||
|
const ipKey = createHash('sha256').update(ip).digest('hex').slice(0, 24);
|
||||||
|
const ipRow = state.ips[ipKey] || (state.ips[ipKey] = { uploads: 0, bytes: 0 });
|
||||||
|
if (ipRow.uploads + 1 > IP_HOURLY_UPLOADS) return 'Too many uploads from this connection. Try again later.';
|
||||||
|
if (ipRow.bytes + bytes > IP_HOURLY_BYTES) return 'Upload burst limit reached. Try again later.';
|
||||||
|
if (state.totalBytes + bytes > TOTAL_BYTES) return 'The relay media budget is full. Try again later.';
|
||||||
|
user.uploads += 1; user.bytes += bytes;
|
||||||
|
ipRow.uploads += 1; ipRow.bytes += bytes;
|
||||||
|
state.totalBytes += bytes;
|
||||||
|
saveState();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function forward(req, res, body) {
|
||||||
|
const headers = { ...req.headers, host: `127.0.0.1:${UPSTREAM_PORT}` };
|
||||||
|
if (body) headers['content-length'] = String(body.length);
|
||||||
|
const upstream = http.request({
|
||||||
|
hostname: '127.0.0.1', port: UPSTREAM_PORT, path: req.url, method: req.method, headers,
|
||||||
|
}, (up) => {
|
||||||
|
res.writeHead(up.statusCode || 502, up.headers);
|
||||||
|
up.pipe(res);
|
||||||
|
});
|
||||||
|
upstream.on('error', () => json(res, 502, { error: 'Media relay unavailable.' }));
|
||||||
|
if (body) upstream.end(body); else req.pipe(upstream);
|
||||||
|
}
|
||||||
|
function opsAuth(req) {
|
||||||
|
const event = decodeToken(req.headers.authorization);
|
||||||
|
if (!event || event.kind !== 27235 || !verifyEvent(event)) return false;
|
||||||
|
if (!ADMIN_PUBKEYS.has(String(event.pubkey).toLowerCase())) return false;
|
||||||
|
if (tag(event, 'method') !== 'GET') return false;
|
||||||
|
if (tag(event, 'u') !== 'https://relay.skate-spots.org/ops') return false;
|
||||||
|
const exp = Number(tag(event, 'expiration') || 0);
|
||||||
|
if (exp && exp < Math.floor(Date.now() / 1000)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (req.url === '/health') { json(res, 200, { ok: true }); return; }
|
||||||
|
if (req.url === '/ops') {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204, {
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'access-control-allow-methods': 'GET, OPTIONS',
|
||||||
|
'access-control-allow-headers': 'Authorization, Content-Type',
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== 'GET' || !opsAuth(req)) { json(res, 401, { error: 'Admin authorization required.' }); return; }
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(STATUS_PATH, 'utf8');
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
});
|
||||||
|
res.end(raw);
|
||||||
|
} catch {
|
||||||
|
json(res, 503, { error: 'Status snapshot unavailable.' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== 'PUT' || req.url !== '/upload') { forward(req, res); return; }
|
||||||
|
|
||||||
|
const contentType = String(req.headers['content-type'] || '').toLowerCase().split(';')[0];
|
||||||
|
if (!IMAGE_TYPES.has(contentType)) { json(res, 415, { error: 'Only JPEG, PNG, WebP, AVIF, HEIC or GIF images can be uploaded.' }); return; }
|
||||||
|
let body;
|
||||||
|
try { body = await readBody(req, MAX_IMAGE_BYTES); }
|
||||||
|
catch (err) { json(res, err?.code === 413 ? 413 : 400, { error: err?.code === 413 ? 'Image is too large. Max 10 MB.' : 'Could not read upload.' }); return; }
|
||||||
|
const event = decodeToken(req.headers.authorization);
|
||||||
|
const problem = checkToken(event, body, contentType);
|
||||||
|
if (problem) { json(res, 401, { error: problem }); return; }
|
||||||
|
rollWindows();
|
||||||
|
const quota = checkQuota(event.pubkey.toLowerCase(), body.length, clientIp(req));
|
||||||
|
if (quota) { json(res, 429, { error: quota, 'retry-after': '3600' }); return; }
|
||||||
|
console.log(`[quota] accepted ${event.pubkey.slice(0, 8)}… ${body.length} bytes ${contentType}`);
|
||||||
|
forward(req, res, body);
|
||||||
|
});
|
||||||
|
server.listen(PORT, '127.0.0.1', () => console.log(`[quota] listening on 127.0.0.1:${PORT}`));
|
||||||
|
|
@ -126,6 +126,14 @@ for _ in 1 2 3 4 5 6; do
|
||||||
done
|
done
|
||||||
[ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)"
|
[ "$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…"
|
log "committing the release…"
|
||||||
git add package.json package-lock.json release-notes.txt
|
git add package.json package-lock.json release-notes.txt
|
||||||
git commit -m "release: $VERSION" >/dev/null
|
git commit -m "release: $VERSION" >/dev/null
|
||||||
|
|
|
||||||
28
scripts/systemd/skatespots-quota.service
Normal file
28
scripts/systemd/skatespots-quota.service
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[Unit]
|
||||||
|
Description=SkateSpots Blossom upload quota proxy
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/home/ubuntu/skatespots-quota
|
||||||
|
ExecStart=/usr/bin/node /home/ubuntu/skatespots-quota/quota-proxy.mjs
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
Environment=PORT=8790
|
||||||
|
Environment=NOSTR_TOOLS_ROOT=/home/ubuntu/skatespots-push/
|
||||||
|
Environment=QUOTA_STATE=/home/ubuntu/skatespots-quota/usage.json
|
||||||
|
Environment=ADMIN_PUBKEYS=20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21,caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b
|
||||||
|
Environment=MAX_IMAGE_BYTES=10485760
|
||||||
|
Environment=DAILY_BYTES=262144000
|
||||||
|
Environment=DAILY_UPLOADS=50
|
||||||
|
Environment=ADMIN_MULTIPLIER=3
|
||||||
|
Environment=IP_HOURLY_BYTES=524288000
|
||||||
|
Environment=IP_HOURLY_UPLOADS=100
|
||||||
|
Environment=TOTAL_BYTES=21474836480
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -6,10 +6,11 @@ import { BECOME_ROLES } from "./lib/becomeRoles";
|
||||||
import { DeepLinkHandler } from "./components/DeepLinkHandler";
|
import { DeepLinkHandler } from "./components/DeepLinkHandler";
|
||||||
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
|
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
|
import { RoutePreloader } from "./components/RoutePreloader";
|
||||||
|
|
||||||
// The map is the homepage, but lazy-loading it keeps its chunk (plus the
|
// HomePage resolves to a lazy map on web (smaller first entry) and to the
|
||||||
// IndexedDB offline-cache layer) out of the initial entry bundle.
|
// eager MapPage via a Capacitor-only Vite alias on Android.
|
||||||
const MapPage = lazyWithRetry(() => import("./pages/MapPage"));
|
import HomePage from "@/pages/HomePage";
|
||||||
|
|
||||||
// Lazy load everything else — each page becomes its own JS chunk
|
// Lazy load everything else — each page becomes its own JS chunk
|
||||||
const Index = lazyWithRetry(() => import("./pages/Index"));
|
const Index = lazyWithRetry(() => import("./pages/Index"));
|
||||||
|
|
@ -22,6 +23,7 @@ const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
|
||||||
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
|
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
|
||||||
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
|
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
|
||||||
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
||||||
|
const OpsPage = lazyWithRetry(() => import("./pages/OpsPage"));
|
||||||
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
||||||
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
||||||
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
|
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
|
||||||
|
|
@ -99,6 +101,7 @@ export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<DeepLinkHandler />
|
<DeepLinkHandler />
|
||||||
|
<RoutePreloader />
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
{/* Per-route boundary: a crash in one page shows a recoverable message
|
{/* Per-route boundary: a crash in one page shows a recoverable message
|
||||||
instead of blanking the whole app. Resets automatically on navigate
|
instead of blanking the whole app. Resets automatically on navigate
|
||||||
|
|
@ -111,9 +114,9 @@ export function AppRouter() {
|
||||||
navigation. Pages no longer wrap themselves in <Layout>. */}
|
navigation. Pages no longer wrap themselves in <Layout>. */}
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
{/* Map is the homepage */}
|
{/* Map is the homepage */}
|
||||||
<Route path="/" element={<MapPage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
{/* /map redirects to / */}
|
{/* /map redirects to / */}
|
||||||
<Route path="/map" element={<MapPage />} />
|
<Route path="/map" element={<HomePage />} />
|
||||||
{/* Legacy home page still accessible */}
|
{/* Legacy home page still accessible */}
|
||||||
<Route path="/home" element={<Index />} />
|
<Route path="/home" element={<Index />} />
|
||||||
<Route path="/events" element={<EventsPage />} />
|
<Route path="/events" element={<EventsPage />} />
|
||||||
|
|
@ -157,6 +160,7 @@ export function AppRouter() {
|
||||||
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
||||||
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
|
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
|
||||||
<Route path="/review" element={<AdminReviewPage />} />
|
<Route path="/review" element={<AdminReviewPage />} />
|
||||||
|
<Route path="/ops" element={<OpsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/create-spot" element={<CreateSpotPage />} />
|
<Route path="/create-spot" element={<CreateSpotPage />} />
|
||||||
<Route path="/create-shop" element={<CreateShopPage />} />
|
<Route path="/create-shop" element={<CreateShopPage />} />
|
||||||
|
|
@ -192,6 +196,8 @@ export function AppRouter() {
|
||||||
<Route path="/license" element={<LicensePage />} />
|
<Route path="/license" element={<LicensePage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPage />} />
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
<Route path="/supporters" element={<SupportersPage />} />
|
<Route path="/supporters" element={<SupportersPage />} />
|
||||||
|
{/* Friendly alias: people naturally type /support. */}
|
||||||
|
<Route path="/support" element={<Navigate to="/supporters" replace />} />
|
||||||
<Route path="/donate" element={<DonatePage />} />
|
<Route path="/donate" element={<DonatePage />} />
|
||||||
{/* Marketplace */}
|
{/* Marketplace */}
|
||||||
<Route path="/marketplace-listings" element={<MarketplacePage />} />
|
<Route path="/marketplace-listings" element={<MarketplacePage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ShieldAlert, ArrowRight } from 'lucide-react';
|
import { ShieldAlert, ArrowRight, Activity } from 'lucide-react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
|
|
@ -24,6 +24,7 @@ export function AdminReviewCard() {
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
<Link
|
<Link
|
||||||
to="/review"
|
to="/review"
|
||||||
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
|
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
|
||||||
|
|
@ -41,5 +42,19 @@ export function AdminReviewCard() {
|
||||||
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/ops"
|
||||||
|
className="block rounded-2xl border border-cyan-500/20 bg-white/[0.02] transition-colors hover:border-cyan-500/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
|
<Activity className="h-5 w-5 shrink-0 text-cyan-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-bold text-cyan-400">Operator status</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">Read-only relay, Cordn, storage and TLS health.</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ import { useTranslation } from '@/contexts/language';
|
||||||
const DISCLAIMER_VERSION = 1;
|
const DISCLAIMER_VERSION = 1;
|
||||||
const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`;
|
const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`;
|
||||||
|
|
||||||
const REPO_URL =
|
const REPO_URL = 'https://git.skate-spots.org/skatespots/SkateSpots';
|
||||||
'https://github.com/search?q=SkateSpots';
|
|
||||||
|
|
||||||
export function BetaDisclaimerDialog() {
|
export function BetaDisclaimerDialog() {
|
||||||
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);
|
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||||
|
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
||||||
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
|
||||||
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
|
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
SKATESPOTS_COORDINATOR,
|
SKATESPOTS_COORDINATOR,
|
||||||
} from '@/lib/coordinator';
|
} from '@/lib/coordinator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { urlDomain } from '@/lib/sanitizeUrl';
|
||||||
|
|
||||||
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
|
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
|
||||||
const s = Math.floor(Date.now() / 1000 - unixSeconds);
|
const s = Math.floor(Date.now() / 1000 - unixSeconds);
|
||||||
|
|
@ -59,6 +61,28 @@ async function copyText(text: string, copiedText: string, failedTitle: string, f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Small live health pill for one relay. Probes only while this card is open. */
|
||||||
|
function RelayHealthPill({ url }: { url: string }) {
|
||||||
|
const { status, reason, recheck } = useRelayStatus(url);
|
||||||
|
const dot =
|
||||||
|
status === 'connected'
|
||||||
|
? 'bg-green-400'
|
||||||
|
: status === 'connecting'
|
||||||
|
? 'bg-blue-400 animate-pulse'
|
||||||
|
: 'bg-red-400';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={recheck}
|
||||||
|
title={reason ?? status}
|
||||||
|
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-2 py-1 text-[10px] text-gray-400 transition-colors hover:border-white/20 hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', dot)} />
|
||||||
|
<span className="truncate">{urlDomain(url)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings → Coordinator.
|
* Settings → Coordinator.
|
||||||
*
|
*
|
||||||
|
|
@ -119,6 +143,11 @@ export function CoordinatorSettingsCard() {
|
||||||
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
|
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
|
||||||
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
|
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
{coordinator.relays.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{coordinator.relays.map((url) => <RelayHealthPill key={url} url={url} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { LabDraftBanner } from '@/components/LabDraftBanner';
|
||||||
import { LabProfileProgress } from '@/components/LabProfileProgress';
|
import { LabProfileProgress } from '@/components/LabProfileProgress';
|
||||||
import { FormWizard, WizardStep } from '@/components/FormWizard';
|
import { FormWizard, WizardStep } from '@/components/FormWizard';
|
||||||
import { isValidNoffer, type EntityType } from '@/lib/types';
|
import { isValidNoffer, type EntityType } from '@/lib/types';
|
||||||
|
import { normalizeClinkCode } from '@/lib/clink';
|
||||||
import type { LabOption } from '@/lib/labExtras';
|
import type { LabOption } from '@/lib/labExtras';
|
||||||
import { labProfilePathFromKind } from '@/lib/labRoutes';
|
import { labProfilePathFromKind } from '@/lib/labRoutes';
|
||||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||||
|
|
@ -327,7 +328,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
||||||
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
|
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
|
||||||
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
|
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
|
||||||
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
|
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
|
||||||
|
|
||||||
// Craft-specific extras
|
// Craft-specific extras
|
||||||
for (const f of extraFields) {
|
for (const f of extraFields) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
||||||
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
|
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
|
||||||
|
import { normalizeClinkCode } from '@/lib/clink';
|
||||||
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
||||||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||||
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
||||||
|
|
@ -571,7 +572,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
if (s.trim()) tags.push(['social', s.trim()]);
|
if (s.trim()) tags.push(['social', s.trim()]);
|
||||||
}
|
}
|
||||||
for (const handle of nip05s) tags.push(['nip05', handle]);
|
for (const handle of nip05s) tags.push(['nip05', handle]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
|
||||||
|
|
||||||
// Members: admins get an `admin` role marker on their `p` tag.
|
// Members: admins get an `admin` role marker on their `p` tag.
|
||||||
for (const pk of members) {
|
for (const pk of members) {
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
DEFAULT_TAB_IDS,
|
DEFAULT_TAB_IDS,
|
||||||
type NavTab,
|
type NavTab,
|
||||||
} from '@/hooks/useNavPreferences';
|
} from '@/hooks/useNavPreferences';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
|
import { MapPin, MoreHorizontal, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface CustomizeNavSheetProps {
|
interface CustomizeNavSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -20,14 +20,14 @@ interface CustomizeNavSheetProps {
|
||||||
/**
|
/**
|
||||||
* Bottom-sheet editor for the mobile nav bar.
|
* Bottom-sheet editor for the mobile nav bar.
|
||||||
*
|
*
|
||||||
* Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right).
|
* Layout model: Map (fixed left) · 3 user-picked · More (fixed right).
|
||||||
* The user toggles up to 3 destinations from the catalog and can reorder them.
|
* The user toggles up to 3 destinations from the catalog and can reorder them.
|
||||||
* Draft state is local until "Done", so changes feel deliberate and cancelable.
|
* Draft state is local until "Done", so changes feel deliberate and cancelable.
|
||||||
*/
|
*/
|
||||||
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
|
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
|
||||||
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
|
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
// Local draft so we can cancel without persisting.
|
// Local draft so we can cancel without persisting.
|
||||||
const [draft, setDraft] = useState<string[]>(middleIds);
|
const [draft, setDraft] = useState<string[]>(middleIds);
|
||||||
|
|
@ -115,8 +115,8 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
|
||||||
}
|
}
|
||||||
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
|
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
|
||||||
})}
|
})}
|
||||||
{/* Fixed Profile */}
|
{/* Fixed More */}
|
||||||
<PreviewSlot icon={User} label={isLoggedIn ? t('navSheet.profile') : t('navSheet.login')} locked accent="#ff0040" />
|
<PreviewSlot icon={MoreHorizontal} label={t('nav.more')} locked accent="#ff0040" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
||||||
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
||||||
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
|
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
|
||||||
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
||||||
|
import { normalizeClinkCode } from '@/lib/clink';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
export const EditProfileForm: React.FC = () => {
|
export const EditProfileForm: React.FC = () => {
|
||||||
|
|
@ -52,7 +53,7 @@ export const EditProfileForm: React.FC = () => {
|
||||||
// Lightning address (LUD-16). Part of NostrMetadata but not in the default
|
// Lightning address (LUD-16). Part of NostrMetadata but not in the default
|
||||||
// form fields, so we manage it here and merge it on save.
|
// form fields, so we manage it here and merge it on save.
|
||||||
const [lud16, setLud16] = useState('');
|
const [lud16, setLud16] = useState('');
|
||||||
const lud16Trimmed = lud16.trim();
|
const lud16Trimmed = lud16.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
|
||||||
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
|
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
|
||||||
|
|
||||||
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
|
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
|
||||||
|
|
@ -224,7 +225,7 @@ export const EditProfileForm: React.FC = () => {
|
||||||
|
|
||||||
// Merge the custom CLINK offer code (or remove it when cleared).
|
// Merge the custom CLINK offer code (or remove it when cleared).
|
||||||
if (nofferValid) {
|
if (nofferValid) {
|
||||||
data.noffer = nofferTrimmed.toLowerCase();
|
data.noffer = normalizeClinkCode(nofferTrimmed);
|
||||||
} else {
|
} else {
|
||||||
delete data.noffer;
|
delete data.noffer;
|
||||||
}
|
}
|
||||||
|
|
@ -493,6 +494,7 @@ export const EditProfileForm: React.FC = () => {
|
||||||
value={lud16}
|
value={lud16}
|
||||||
onChange={setLud16}
|
onChange={setLud16}
|
||||||
placeholder="you@yourdomain.com"
|
placeholder="you@yourdomain.com"
|
||||||
|
inputMode="email"
|
||||||
status={
|
status={
|
||||||
lud16Invalid
|
lud16Invalid
|
||||||
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
|
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
|
||||||
|
|
@ -713,7 +715,8 @@ const FieldRow: React.FC<{
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
status: { kind: 'hint' | 'ok' | 'error'; text: string };
|
status: { kind: 'hint' | 'ok' | 'error'; text: string };
|
||||||
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status }) => (
|
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
||||||
|
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status, inputMode }) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold">
|
<Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span>
|
<span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span>
|
||||||
|
|
@ -726,6 +729,7 @@ const FieldRow: React.FC<{
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
inputMode={inputMode}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
{status.kind === 'error' ? (
|
{status.kind === 'error' ? (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ZapDialog } from '@/components/ZapDialog';
|
import { LazyZapDialog } from '@/components/LazyZapDialog';
|
||||||
import { useZaps } from '@/hooks/useZaps';
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -64,7 +64,7 @@ export function FundingGoalCard({
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ZapDialog target={event}>
|
<LazyZapDialog target={event}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#ccff00] py-2 text-sm font-bold uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
|
className="flex w-full items-center justify-center gap-2 rounded-md bg-[#ccff00] py-2 text-sm font-bold uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
|
||||||
|
|
@ -72,7 +72,7 @@ export function FundingGoalCard({
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
{reached ? 'Keep supporting' : 'Fund this event'}
|
{reached ? 'Keep supporting' : 'Fund this event'}
|
||||||
</button>
|
</button>
|
||||||
</ZapDialog>
|
</LazyZapDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { useTranslation } from '@/contexts/language';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
|
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const VISIBLE_LANGUAGE_OPTIONS = LANGUAGE_OPTIONS.filter((language) => !language.hidden);
|
||||||
|
|
||||||
interface LanguageToggleProps {
|
interface LanguageToggleProps {
|
||||||
/** Show only the flag icon (compact mode) */
|
/** Show only the flag icon (compact mode) */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
|
@ -65,13 +67,13 @@ export function LanguageToggle({ compact = false, inline = false, className, var
|
||||||
|
|
||||||
const safeQuery = query.trim().toLowerCase();
|
const safeQuery = query.trim().toLowerCase();
|
||||||
const filteredLanguages = safeQuery
|
const filteredLanguages = safeQuery
|
||||||
? LANGUAGE_OPTIONS.filter(
|
? VISIBLE_LANGUAGE_OPTIONS.filter(
|
||||||
(l) =>
|
(l) =>
|
||||||
l.name.toLowerCase().includes(safeQuery) ||
|
l.name.toLowerCase().includes(safeQuery) ||
|
||||||
l.nativeName.toLowerCase().includes(safeQuery) ||
|
l.nativeName.toLowerCase().includes(safeQuery) ||
|
||||||
l.code.toLowerCase().includes(safeQuery)
|
l.code.toLowerCase().includes(safeQuery)
|
||||||
)
|
)
|
||||||
: LANGUAGE_OPTIONS;
|
: VISIBLE_LANGUAGE_OPTIONS;
|
||||||
|
|
||||||
const currentLanguage =
|
const currentLanguage =
|
||||||
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
|
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
|
||||||
|
|
@ -196,7 +198,7 @@ export function LanguageSelector() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
|
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{LANGUAGE_OPTIONS.map((lang) => (
|
{VISIBLE_LANGUAGE_OPTIONS.map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => setLanguage(lang.code)}
|
onClick={() => setLanguage(lang.code)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
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 { nip19 } from 'nostr-tools';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isNativePlatform } from '@/lib/platform';
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
|
|
@ -39,7 +39,6 @@ import {
|
||||||
User,
|
User,
|
||||||
Plus,
|
Plus,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Database,
|
|
||||||
Package,
|
Package,
|
||||||
Heart,
|
Heart,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
|
@ -52,6 +51,8 @@ import {
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ArrowLeft,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||||
|
|
@ -111,6 +112,30 @@ function hasCashuWalletHint(): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-level Suspense fallback. Keeps the header and bottom nav mounted while a
|
||||||
|
* lazy route chunk loads, so tapping a menu item never blanks the whole app.
|
||||||
|
* The fade is delayed slightly: a fast chunk resolves before it becomes visible.
|
||||||
|
*/
|
||||||
|
function RouteLoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl px-4 py-6" aria-hidden="true">
|
||||||
|
<div style={{ animation: 'pageFallbackIn 180ms ease 120ms both' }}>
|
||||||
|
<div className="animate-pulse motion-reduce:animate-none space-y-5">
|
||||||
|
<div className="h-8 w-48 rounded-lg bg-white/[0.07]" />
|
||||||
|
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
|
||||||
|
<div className="grid gap-3 pt-3 sm:grid-cols-2">
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
<div className="h-28 rounded-xl bg-white/[0.05]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
|
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
|
||||||
* there are no children. */
|
* there are no children. */
|
||||||
|
|
@ -159,6 +184,7 @@ function NavLink({
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
|
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
|
|
@ -195,8 +221,12 @@ function MobileHeaderUserMenu({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex items-center gap-0.5">
|
||||||
<button className="touch-manipulation relative flex items-center gap-2 px-2 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 active:scale-95 transition-transform">
|
<Link
|
||||||
|
to={`/${nip19.npubEncode(pubkey)}`}
|
||||||
|
aria-label={t('chromeUi.viewMyProfile')}
|
||||||
|
className="touch-manipulation relative flex items-center gap-1.5 rounded-l-full rounded-r-md border border-green-500/30 bg-green-500/10 px-1.5 py-1.5 transition-transform active:scale-95"
|
||||||
|
>
|
||||||
<Avatar className="h-7 w-7 border-2 border-green-500/50">
|
<Avatar className="h-7 w-7 border-2 border-green-500/50">
|
||||||
<AvatarImage src={avatar} alt={displayName} />
|
<AvatarImage src={avatar} alt={displayName} />
|
||||||
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
|
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
|
||||||
|
|
@ -205,14 +235,19 @@ function MobileHeaderUserMenu({
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
||||||
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
|
|
||||||
<CheckCircle2 className="h-3 w-3 inline mr-1" />
|
|
||||||
{t('chromeUi.inBadge')}
|
|
||||||
</span>
|
|
||||||
{/* Green online indicator dot */}
|
{/* Green online indicator dot */}
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
|
<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
|
||||||
|
type="button"
|
||||||
|
aria-label={t('chromeUi.menu')}
|
||||||
|
className="flex h-9 w-5 items-center justify-center rounded-r-full text-green-400/80 transition-colors hover:text-green-300 active:scale-95"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
</div>
|
||||||
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
|
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
|
||||||
{/* User info header */}
|
{/* User info header */}
|
||||||
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||||
|
|
@ -248,12 +283,6 @@ function MobileHeaderUserMenu({
|
||||||
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
|
||||||
<span className="ml-auto text-xs text-green-500">🛹</span>
|
<span className="ml-auto text-xs text-green-500">🛹</span>
|
||||||
</DropdownMenuItem>
|
</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>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
|
|
@ -266,36 +295,6 @@ function MobileHeaderUserMenu({
|
||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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 ── */}
|
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
@ -342,21 +341,17 @@ function MobileHeaderUserMenu({
|
||||||
function MobileNav({
|
function MobileNav({
|
||||||
isActive,
|
isActive,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
userPubkey,
|
|
||||||
userAvatar,
|
|
||||||
userName,
|
|
||||||
t,
|
t,
|
||||||
onCustomize,
|
onCustomize,
|
||||||
|
onMore,
|
||||||
fixedMapTab,
|
fixedMapTab,
|
||||||
middleTabs,
|
middleTabs,
|
||||||
}: {
|
}: {
|
||||||
isActive: (path: string) => boolean;
|
isActive: (path: string) => boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
userPubkey?: string;
|
|
||||||
userAvatar?: string;
|
|
||||||
userName?: string;
|
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
onCustomize: () => void;
|
onCustomize: () => void;
|
||||||
|
onMore: () => void;
|
||||||
fixedMapTab: NavTab;
|
fixedMapTab: NavTab;
|
||||||
middleTabs: NavTab[];
|
middleTabs: NavTab[];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -399,6 +394,7 @@ function MobileNav({
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
to={tab.href}
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
onTouchStart={startPress}
|
onTouchStart={startPress}
|
||||||
onTouchEnd={cancelPress}
|
onTouchEnd={cancelPress}
|
||||||
onTouchMove={cancelPress}
|
onTouchMove={cancelPress}
|
||||||
|
|
@ -457,60 +453,21 @@ function MobileNav({
|
||||||
{/* CUSTOM: 3 middle tabs */}
|
{/* CUSTOM: 3 middle tabs */}
|
||||||
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
|
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
|
||||||
|
|
||||||
{/* FIXED: Profile / Login tab.
|
{/* FIXED: More — the burger menu owns profile, login, wallet,
|
||||||
Logged in → your public profile (View Profile).
|
settings and every secondary section. */}
|
||||||
Logged out → Settings (login lives there).
|
<button
|
||||||
Long-press / right-click still opens nav customization. */}
|
type="button"
|
||||||
{(() => {
|
onClick={onMore}
|
||||||
const profileHref = isLoggedIn && userPubkey ? `/${nip19.npubEncode(userPubkey)}` : '/settings';
|
className="group relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-1 px-1 py-1.5 active:scale-90 transition-transform duration-150 text-gray-500"
|
||||||
const tabActive = isActive(profileHref);
|
aria-label={t('nav.more')}
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={profileHref}
|
|
||||||
onTouchStart={startPress}
|
|
||||||
onTouchEnd={cancelPress}
|
|
||||||
onTouchMove={cancelPress}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onCustomize();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'group relative flex flex-col items-center justify-center gap-1 py-1.5 px-1 flex-1 touch-manipulation active:scale-90 transition-transform duration-150',
|
|
||||||
tabActive ? 'text-[#ff0040]' : 'text-gray-500'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span
|
<span className="relative flex h-9 w-12 items-center justify-center rounded-2xl bg-transparent transition-all duration-300">
|
||||||
className={cn(
|
<MoreHorizontal className="h-[22px] w-[22px] transition-transform duration-300 group-active:scale-95" />
|
||||||
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
|
|
||||||
tabActive ? 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]' : 'bg-transparent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoggedIn && userPubkey ? (
|
|
||||||
<div className="relative">
|
|
||||||
<Avatar className={cn(
|
|
||||||
'h-[24px] w-[24px] border-2 transition-transform duration-300',
|
|
||||||
tabActive ? 'border-[#ff0040] scale-110' : 'border-gray-600 group-active:scale-95'
|
|
||||||
)}>
|
|
||||||
<AvatarImage src={userAvatar} alt={userName} />
|
|
||||||
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-[8px] font-bold">
|
|
||||||
{(userName || '??').slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-green-500 border border-black rounded-full" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<User className={cn('h-[22px] w-[22px] transition-transform duration-300', tabActive ? 'scale-110' : 'group-active:scale-95')} />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span className="block w-full truncate text-center text-[9px] xs:text-[10px] font-semibold uppercase leading-tight tracking-wide">
|
||||||
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
|
{t('nav.more')}
|
||||||
tabActive ? 'font-extrabold' : 'font-semibold',
|
|
||||||
)}>
|
|
||||||
{isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')}
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</button>
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -519,6 +476,8 @@ function MobileNav({
|
||||||
|
|
||||||
export function Layout({ children, noPadding }: LayoutProps) {
|
export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const location = useLocation();
|
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
|
// 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.
|
// padding even though it shares this one persistent layout with every route.
|
||||||
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
|
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
|
||||||
|
|
@ -526,6 +485,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const hideSasquatch = /^\/(become|create|edit|supporters|onboarding)(\/|$)/.test(location.pathname);
|
const hideSasquatch = /^\/(become|create|edit|supporters|onboarding)(\/|$)/.test(location.pathname);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [mobileLanguageView, setMobileLanguageView] = useState(false);
|
const [mobileLanguageView, setMobileLanguageView] = useState(false);
|
||||||
|
|
||||||
|
// Back/forward, notification taps and global search can change routes without
|
||||||
|
// clicking a menu row. Never leave the burger sheet covering the new page.
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
setMobileLanguageView(false);
|
||||||
|
}, [location.pathname]);
|
||||||
// One auth dialog for the header + burger menu. Owning it here (not inside
|
// One auth dialog for the header + burger menu. Owning it here (not inside
|
||||||
// LoginArea) keeps it above the sheet and outside the sheet's unmount tree.
|
// LoginArea) keeps it above the sheet and outside the sheet's unmount tree.
|
||||||
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
|
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
|
||||||
|
|
@ -585,7 +551,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
|
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
|
||||||
const author = useAuthor(user?.pubkey || '');
|
const author = useAuthor(user?.pubkey || '');
|
||||||
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
|
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
|
||||||
const userAvatar = author.data?.metadata?.picture;
|
|
||||||
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
|
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
|
||||||
const {
|
const {
|
||||||
primaryTabs: desktopPrimaryTabs,
|
primaryTabs: desktopPrimaryTabs,
|
||||||
|
|
@ -620,7 +585,11 @@ 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">
|
<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
|
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the
|
||||||
logo never gets squeezed into (or glowed over by) the nav. */}
|
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
|
<img
|
||||||
src="/images/icon-1024.png"
|
src="/images/icon-1024.png"
|
||||||
alt="SkateSpots"
|
alt="SkateSpots"
|
||||||
|
|
@ -839,13 +808,34 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
{/* Mobile Header */}
|
{/* 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">
|
<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">
|
<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
|
<img
|
||||||
src="/images/icon-1024.png"
|
src="/images/icon-1024.png"
|
||||||
alt="SkateSpots"
|
alt="SkateSpots"
|
||||||
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
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'
|
textShadow: '0 0 8px #ff0040'
|
||||||
}}>SKATESPOTS</span>
|
}}>SKATESPOTS</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -1183,7 +1173,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
|
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
|
||||||
>
|
>
|
||||||
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
{children ?? <Outlet />}
|
{children ?? <Outlet />}
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer - Desktop */}
|
{/* Footer - Desktop */}
|
||||||
|
|
@ -1231,7 +1223,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
<span>{t('chromeUi.notSponsoredBy')}</span>
|
<span>{t('chromeUi.notSponsoredBy')}</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<img
|
<img
|
||||||
src="https://opensats.org/favicon.ico"
|
src="/images/opensats.ico"
|
||||||
alt="OpenSats"
|
alt="OpenSats"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1248,10 +1240,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
<MobileNav
|
<MobileNav
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
|
onMore={() => setMobileMenuOpen(true)}
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={!!user}
|
||||||
userPubkey={user?.pubkey}
|
|
||||||
userAvatar={userAvatar}
|
|
||||||
userName={displayName}
|
|
||||||
t={t}
|
t={t}
|
||||||
onCustomize={() => setShowCustomizeNav(true)}
|
onCustomize={() => setShowCustomizeNav(true)}
|
||||||
fixedMapTab={fixedMapTab}
|
fixedMapTab={fixedMapTab}
|
||||||
|
|
|
||||||
72
src/components/LazyZapDialog.tsx
Normal file
72
src/components/LazyZapDialog.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import {
|
||||||
|
cloneElement,
|
||||||
|
isValidElement,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useState,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import type { Event } from 'nostr-tools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight zap trigger.
|
||||||
|
*
|
||||||
|
* The full ZapDialog pulls in the Cashu wallet stack (`useCashu`, mint repos,
|
||||||
|
* token operations) — several hundred KB that used to land in the initial
|
||||||
|
* bundle because every SpotCard rendered a ZapButton. This wrapper keeps only
|
||||||
|
* the trigger in the entry chunk and loads the actual dialog on first tap.
|
||||||
|
*/
|
||||||
|
const LoadedZapDialog = lazy(() =>
|
||||||
|
import('@/components/ZapDialog').then((m) => ({ default: m.ZapDialog })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface LazyZapDialogProps {
|
||||||
|
target: Event;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LazyZapDialog({ target, children, className }: LazyZapDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (isValidElement(children)) {
|
||||||
|
const child = children as ReactElement<{ onClick?: (event: MouseEvent) => void }>;
|
||||||
|
child.props.onClick?.(event);
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = isValidElement(children) ? (
|
||||||
|
cloneElement(
|
||||||
|
children as ReactElement<{ onClick?: (event: MouseEvent) => void; className?: string }>,
|
||||||
|
{
|
||||||
|
onClick: handleClick,
|
||||||
|
className: [
|
||||||
|
(children as ReactElement<{ className?: string }>).props.className,
|
||||||
|
'cursor-pointer',
|
||||||
|
].filter(Boolean).join(' '),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className={className} onClick={handleClick}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger}
|
||||||
|
{open && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoadedZapDialog
|
||||||
|
target={target}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
hideTrigger
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import type { MarketplaceItem } from '@/lib/types';
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
import { PayWithSats } from '@/components/PayWithSats';
|
import { PayWithSats } from '@/components/PayWithSats';
|
||||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
|
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||||||
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
||||||
import { useTranslation } from '@/contexts/language';
|
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')}
|
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{isOwner && <PaymentReadinessNote />}
|
||||||
|
|
||||||
{mine.map((thread) => {
|
{mine.map((thread) => {
|
||||||
const latest = thread.latest;
|
const latest = thread.latest;
|
||||||
const other = latest.author === user.pubkey
|
const other = latest.author === user.pubkey
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function MapFooterStrip() {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1.5 text-gray-500">
|
<span className="flex items-center gap-1.5 text-gray-500">
|
||||||
{t('chromeUi.notSponsoredBy')}
|
{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 className="font-medium text-gray-400">OpenSats</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ export function PaymentReadinessNote() {
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
|
|
||||||
const lud16 = metadata?.lud16;
|
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.
|
// 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 rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment;
|
||||||
const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp);
|
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" />
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
|
||||||
<span>
|
<span>
|
||||||
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
|
{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')}
|
{t('marketUi.paymentReadiness.nostrProfile')}
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
{t('marketUi.paymentReadiness.notReadyAfter')}
|
{t('marketUi.paymentReadiness.notReadyAfter')}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { BadgeCheck, ExternalLink } from 'lucide-react';
|
import { BadgeCheck, ExternalLink } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
|
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
|
||||||
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||||
|
|
||||||
/** The app logo is already served with the site. */
|
/** The app logo is already served with the site. */
|
||||||
const SKATESPOTS_LOGO = '/images/logo-icon.png';
|
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)}>
|
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
||||||
{badges.map((b, i) => {
|
{badges.map((b, i) => {
|
||||||
const app = isApp(b);
|
const app = isApp(b);
|
||||||
|
const safeUrl = b.url ? sanitizeUrl(b.url) : null;
|
||||||
const cls = cn(
|
const cls = cn(
|
||||||
'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors',
|
'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',
|
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'} />
|
<BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[12rem] truncate">{b.label}</span>
|
<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
|
<a
|
||||||
key={`${b.label}-${i}`}
|
key={`${b.label}-${i}`}
|
||||||
href={b.url}
|
href={safeUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={b.url}
|
title={safeUrl}
|
||||||
className={cls}
|
className={cls}
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
|
|
|
||||||
69
src/components/RelayDivergenceCard.tsx
Normal file
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/RoutePreloader.tsx
Normal file
82
src/components/RoutePreloader.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover / touch prefetch for the primary navigation destinations.
|
||||||
|
*
|
||||||
|
* The route chunks stay lazy (so first paint is not blocked), but once the user
|
||||||
|
* shows intent — hovers a menu row, tabs into a link, or touches it — the page
|
||||||
|
* module starts downloading. On mobile the touchstart head start is often enough
|
||||||
|
* to avoid the page skeleton entirely. Only modules are fetched; no page hooks
|
||||||
|
* mount, so this never triggers relay work or an Amber prompt.
|
||||||
|
*/
|
||||||
|
const loaders: Record<string, () => Promise<unknown>> = {
|
||||||
|
'/lab': () => import('@/pages/LabPage'),
|
||||||
|
'/events': () => import('@/pages/EventsPage'),
|
||||||
|
'/crews': () => import('@/pages/CrewsPage'),
|
||||||
|
'/community': () => import('@/pages/CommunityPage'),
|
||||||
|
'/skaters': () => import('@/pages/SkatersPage'),
|
||||||
|
'/messages': () => import('@/pages/DirectMessagesPage'),
|
||||||
|
'/settings': () => import('@/pages/SettingsPage'),
|
||||||
|
'/wallet': () => import('@/pages/WalletPage'),
|
||||||
|
'/stash': () => import('@/pages/StashPage'),
|
||||||
|
'/marketplace': () => import('@/pages/MarketplaceHubPage'),
|
||||||
|
'/stats': () => import('@/pages/CommunityStatsPage'),
|
||||||
|
'/collections': () => import('@/pages/SpotCollectionsPage'),
|
||||||
|
'/glossary': () => import('@/pages/GlossaryPage'),
|
||||||
|
'/about': () => import('@/pages/AboutPage'),
|
||||||
|
'/card': () => import('@/pages/RiderCardPage'),
|
||||||
|
'/supporters': () => import('@/pages/SupportersPage'),
|
||||||
|
'/newsletter': () => import('@/pages/NewsletterPage'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const preloaded = new Set<string>();
|
||||||
|
|
||||||
|
function routePath(anchor: HTMLAnchorElement): string | null {
|
||||||
|
const href = anchor.getAttribute('href') ?? '';
|
||||||
|
if (!href || href.startsWith('http') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// HashRouter renders to="#/lab" as href="#/lab".
|
||||||
|
if (href.startsWith('#/')) return href.slice(1).split('?')[0] || '/';
|
||||||
|
try {
|
||||||
|
const url = new URL(anchor.href, window.location.href);
|
||||||
|
if (url.hash.startsWith('#/')) return url.hash.slice(1).split('?')[0] || '/';
|
||||||
|
return url.pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preloadFromTarget(target: EventTarget | null): void {
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
const anchor = target.closest('a[href]');
|
||||||
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||||
|
const path = routePath(anchor);
|
||||||
|
if (!path || preloaded.has(path)) return;
|
||||||
|
const loader = loaders[path];
|
||||||
|
if (!loader) return;
|
||||||
|
preloaded.add(path);
|
||||||
|
void loader().catch(() => {
|
||||||
|
// A failed prefetch is harmless; lazyWithRetry will retry on navigation.
|
||||||
|
preloaded.delete(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutePreloader() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNativePlatform()) return;
|
||||||
|
|
||||||
|
const onIntent = (event: Event) => preloadFromTarget(event.target);
|
||||||
|
document.addEventListener('pointerover', onIntent, { capture: true, passive: true });
|
||||||
|
document.addEventListener('focusin', onIntent, { capture: true, passive: true });
|
||||||
|
document.addEventListener('touchstart', onIntent, { capture: true, passive: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerover', onIntent, true);
|
||||||
|
document.removeEventListener('focusin', onIntent, true);
|
||||||
|
document.removeEventListener('touchstart', onIntent, true);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -23,23 +23,23 @@ import { useTranslation } from '@/contexts/language';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SESSION_KEY = 'splash-shown-session';
|
const SESSION_KEY = 'splash-shown-session';
|
||||||
const MIN_MS = 900; // choreography needs room to breathe
|
const MIN_MS = 380; // keep the brand beat, lose the artificial wait
|
||||||
const WARM_GRACE_MS = 550; // wait after connection for content to stream in
|
const WARM_GRACE_MS = 180; // a short breath after relays connect
|
||||||
const MAX_MS = 2600; // hard cap — never block on a slow/offline network
|
const MAX_MS = 900; // hard cap — reveal the shell even on a dead network
|
||||||
const FADE_MS = 480;
|
const FADE_MS = 280; // iris-close exit
|
||||||
const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing
|
const READY_HOLD_MS = 120; // show the ready check, then move
|
||||||
|
|
||||||
const SPARKS = [
|
const SPARKS = [
|
||||||
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
|
{ sx: '-8px', sy: '18px', ex: '-54px', ey: '44px', delay: '0ms' },
|
||||||
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '90ms' },
|
{ sx: '4px', sy: '22px', ex: '38px', ey: '58px', delay: '20ms' },
|
||||||
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '180ms' },
|
{ sx: '-16px', sy: '10px', ex: '-72px', ey: '18px', delay: '40ms' },
|
||||||
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '260ms' },
|
{ sx: '12px', sy: '14px', ex: '66px', ey: '30px', delay: '60ms' },
|
||||||
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '340ms' },
|
{ sx: '-4px', sy: '24px', ex: '-28px', ey: '76px', delay: '80ms' },
|
||||||
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '430ms' },
|
{ sx: '8px', sy: '24px', ex: '22px', ey: '78px', delay: '100ms' },
|
||||||
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '520ms' },
|
{ sx: '-12px', sy: '2px', ex: '-48px', ey: '-16px', delay: '120ms' },
|
||||||
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '610ms' },
|
{ sx: '14px', sy: '4px', ex: '52px', ey: '-12px', delay: '140ms' },
|
||||||
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '700ms' },
|
{ sx: '-2px', sy: '28px', ex: '-10px', ey: '88px', delay: '160ms' },
|
||||||
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '790ms' },
|
{ sx: '2px', sy: '28px', ex: '12px', ey: '90px', delay: '180ms' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_KEYS = [
|
const STATUS_KEYS = [
|
||||||
|
|
@ -75,7 +75,7 @@ export function SplashScreen() {
|
||||||
if (!visible || ready) return;
|
if (!visible || ready) return;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
|
setStatusIndex((i) => (i + 1) % STATUS_KEYS.length);
|
||||||
}, 640);
|
}, 380);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [visible, ready]);
|
}, [visible, ready]);
|
||||||
|
|
||||||
|
|
@ -84,8 +84,8 @@ export function SplashScreen() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || ready) return;
|
if (!visible || ready) return;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
setProgress((p) => (p >= 85 ? p : p + Math.max(0.6, (85 - p) * 0.045)));
|
setProgress((p) => (p >= 85 ? p : p + Math.max(0.9, (85 - p) * 0.065)));
|
||||||
}, 90);
|
}, 60);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [visible, ready]);
|
}, [visible, ready]);
|
||||||
|
|
||||||
|
|
@ -155,9 +155,24 @@ export function SplashScreen() {
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
|
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
opacity: leaving ? 0 : 1,
|
// The exit is an aperture-style iris closing into the logo. It reveals
|
||||||
transform: leaving && !prefersReducedMotion ? 'scale(1.04)' : 'scale(1)',
|
// the already-painted app from the edges without moving the map. Users
|
||||||
transition: `opacity ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1), transform ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1)`,
|
// who prefer reduced motion get a simple, quick fade instead.
|
||||||
|
opacity: leaving && prefersReducedMotion ? 0 : 1,
|
||||||
|
clipPath: prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: leaving
|
||||||
|
? 'circle(0px at 50% 42%)'
|
||||||
|
: 'circle(150vmax at 50% 42%)',
|
||||||
|
WebkitClipPath: prefersReducedMotion
|
||||||
|
? undefined
|
||||||
|
: leaving
|
||||||
|
? 'circle(0px at 50% 42%)'
|
||||||
|
: 'circle(150vmax at 50% 42%)',
|
||||||
|
transition: prefersReducedMotion
|
||||||
|
? `opacity ${FADE_MS}ms ease`
|
||||||
|
: `clip-path ${FADE_MS}ms cubic-bezier(0.7,0,0.84,0)`,
|
||||||
|
willChange: 'clip-path',
|
||||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
paddingLeft: 'env(safe-area-inset-left, 0px)',
|
paddingLeft: 'env(safe-area-inset-left, 0px)',
|
||||||
|
|
@ -189,10 +204,22 @@ export function SplashScreen() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col items-center px-8">
|
<div
|
||||||
{/* The orange wheel mark rolls in, keeps a slow roll, and throws sparks */}
|
className="relative z-10 flex flex-col items-center px-8"
|
||||||
<div className="relative" style={anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both')}>
|
style={{
|
||||||
{/* Rotating conic ring — orange to match the wheel, with a lime kick */}
|
// Keep the mark visible all the way through the iris-close exit.
|
||||||
|
// The shrinking aperture clips it away, so there is never a dead
|
||||||
|
// black frame between the splash and the app underneath.
|
||||||
|
opacity: 1,
|
||||||
|
transform: leaving && !prefersReducedMotion ? 'translateY(-4px)' : 'translateY(0)',
|
||||||
|
transition: 'transform 280ms cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* The mark arrives from the native splash's centre and settles into the
|
||||||
|
column. No rotation: a quick, grounded drop with one landing burst. */}
|
||||||
|
<div style={anim('splashLogoHandoff 440ms cubic-bezier(0.22,1,0.36,1) both')}>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Static conic halo — colour and breathing glow, no spin */}
|
||||||
<div
|
<div
|
||||||
className="absolute -inset-3 rounded-full"
|
className="absolute -inset-3 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -200,15 +227,14 @@ export function SplashScreen() {
|
||||||
'conic-gradient(from 0deg, transparent 0deg, #ff6a00 90deg, #ccff00 190deg, transparent 310deg)',
|
'conic-gradient(from 0deg, transparent 0deg, #ff6a00 90deg, #ccff00 190deg, transparent 310deg)',
|
||||||
opacity: ready ? 0.9 : 0.58,
|
opacity: ready ? 0.9 : 0.58,
|
||||||
filter: 'blur(7px)',
|
filter: 'blur(7px)',
|
||||||
transition: 'opacity 400ms ease',
|
transition: 'opacity 240ms ease',
|
||||||
...anim('splashSpin 3.2s linear infinite'),
|
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{/* Mask ring interior so only a thin halo shows */}
|
{/* Mask ring interior so only a thin halo shows */}
|
||||||
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
|
<div className="absolute -inset-1 rounded-full bg-[#050505]" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Sparks are decorative and disabled for reduced-motion users. */}
|
{/* One-shot landing sparks, disabled for reduced-motion users. */}
|
||||||
{!prefersReducedMotion && (
|
{!prefersReducedMotion && (
|
||||||
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
|
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||||
{SPARKS.map((spark, index) => (
|
{SPARKS.map((spark, index) => (
|
||||||
|
|
@ -227,25 +253,16 @@ export function SplashScreen() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bounce wrapper + rolling wheel. Nested transforms keep the spin
|
|
||||||
from fighting the entry animation. */}
|
|
||||||
<div
|
<div
|
||||||
style={anim('splashLogoRollIn 720ms cubic-bezier(0.22,1,0.36,1) both')}
|
style={anim('splashLogoDrop 380ms cubic-bezier(0.22,1,0.36,1) both')}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
style={anim('splashHover 1.9s ease-in-out infinite')}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<div style={anim('splashWheelRoll 5.4s linear infinite')} className="relative">
|
|
||||||
<img
|
<img
|
||||||
src="/images/icon-1024.png"
|
src="/images/icon-1024.png"
|
||||||
alt="SkateSpots"
|
alt="SkateSpots"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
|
className="relative h-28 w-28 select-none drop-shadow-[0_0_34px_rgba(255,106,0,0.65)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Contact shadow / dust line under the wheel */}
|
{/* Contact shadow / dust line under the wheel */}
|
||||||
<div
|
<div
|
||||||
className="absolute -bottom-2 left-1/2 h-2 w-20 -translate-x-1/2 rounded-full bg-[#ff6a00]/30 blur-md"
|
className="absolute -bottom-2 left-1/2 h-2 w-20 -translate-x-1/2 rounded-full bg-[#ff6a00]/30 blur-md"
|
||||||
|
|
@ -254,14 +271,15 @@ export function SplashScreen() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Wordmark */}
|
{/* Wordmark */}
|
||||||
<h1
|
<h1
|
||||||
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
|
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
|
||||||
style={{
|
style={{
|
||||||
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
|
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
|
||||||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||||
animationDelay: prefersReducedMotion ? undefined : '120ms',
|
animationDelay: prefersReducedMotion ? undefined : '0ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
SKATESPOTS
|
SKATESPOTS
|
||||||
|
|
@ -271,8 +289,8 @@ export function SplashScreen() {
|
||||||
<div
|
<div
|
||||||
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
|
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
|
||||||
style={{
|
style={{
|
||||||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||||
animationDelay: prefersReducedMotion ? undefined : '220ms',
|
animationDelay: prefersReducedMotion ? undefined : '30ms',
|
||||||
}}
|
}}
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
|
|
@ -283,7 +301,7 @@ export function SplashScreen() {
|
||||||
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
|
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
|
||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
transition: 'width 420ms cubic-bezier(0.4,0,0.2,1)',
|
transition: 'width 240ms cubic-bezier(0.4,0,0.2,1)',
|
||||||
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
|
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -305,8 +323,8 @@ export function SplashScreen() {
|
||||||
<div
|
<div
|
||||||
className="mt-5 flex h-4 items-center gap-2"
|
className="mt-5 flex h-4 items-center gap-2"
|
||||||
style={{
|
style={{
|
||||||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
...anim('splashRise 320ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||||
animationDelay: prefersReducedMotion ? undefined : '320ms',
|
animationDelay: prefersReducedMotion ? undefined : '60ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ready ? (
|
{ready ? (
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||||||
|
|
@ -23,6 +23,7 @@ import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
||||||
import { ReportDialog } from '@/components/ReportDialog';
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
import { MuteMenuItem } from '@/components/MuteMenuItem';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
||||||
import type { SkateSpot } from '@/lib/types';
|
import type { SkateSpot } from '@/lib/types';
|
||||||
import { ACCESS_TYPE_INFO, SHELTER_INFO, SKATE_SHOP_KIND } 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 { ACCESS_TYPE_KEYS, SHELTER_SHORT_KEYS } from '@/lib/labelKeys';
|
||||||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface SpotCardProps {
|
interface SpotCardProps {
|
||||||
|
|
@ -48,10 +50,13 @@ export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||||||
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
||||||
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
||||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
// Determine if this is a shop or a spot
|
// Determine if this is a shop or a spot
|
||||||
const isShop = spot.types.includes('shop');
|
const isShop = spot.types.includes('shop');
|
||||||
const isOwner = user?.pubkey === spot.pubkey;
|
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}`;
|
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).
|
// 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" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</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 && (
|
{!isOwner && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
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 */}
|
{/* Report Dialog */}
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
open={showReportDialog}
|
open={showReportDialog}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,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')}`
|
? `${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')}
|
: t('marketUi.reputation.awaiting')}
|
||||||
</p>
|
</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 && (
|
{data.withPaymentProof > 0 && (
|
||||||
<p className="text-xs text-gray-500">
|
<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>
|
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Ways to support" — three purpose-first cards; each scrolls to its rail by id
|
* "Ways to support" — three purpose-first cards; each scrolls to its rail by id
|
||||||
* (#support-lightning / #support-silent / #support-angor), so it works anywhere
|
* (#support-lightning / #support-silent / #support-angor), so it works anywhere
|
||||||
* those anchors exist (/supporters and /donate).
|
* those anchors exist (/supporters and /donate).
|
||||||
*/
|
*/
|
||||||
export function WaysToSupport({ className }: { className?: string }) {
|
export function WaysToSupport({
|
||||||
|
className,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
active?: string;
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ways = [
|
const ways = [
|
||||||
{ id: 'support-lightning', emoji: '⚡', title: t('walletUi.supporters.wayLightningTitle'), desc: t('walletUi.supporters.wayLightningDesc') },
|
{ id: 'support-lightning', emoji: '⚡', title: t('walletUi.supporters.wayLightningTitle'), desc: t('walletUi.supporters.wayLightningDesc') },
|
||||||
|
|
@ -13,16 +22,29 @@ export function WaysToSupport({ className }: { className?: string }) {
|
||||||
{ id: 'support-angor', emoji: '⛓', title: t('walletUi.supporters.wayAngorTitle'), desc: t('walletUi.supporters.wayAngorDesc') },
|
{ id: 'support-angor', emoji: '⛓', title: t('walletUi.supporters.wayAngorTitle'), desc: t('walletUi.supporters.wayAngorDesc') },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-1 gap-3 sm:grid-cols-3 ${className ?? ''}`}>
|
<div className={`grid grid-cols-3 gap-2 ${className ?? ''}`}>
|
||||||
{ways.map((w) => (
|
{ways.map((w) => (
|
||||||
<button
|
<button
|
||||||
key={w.id}
|
key={w.id}
|
||||||
onClick={() => document.getElementById(w.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
type="button"
|
||||||
className="min-h-0 min-w-0 rounded-xl border border-white/10 bg-black/60 p-4 text-left transition-colors hover:border-[#ccff00]/40 hover:bg-white/5"
|
aria-pressed={active === w.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(w.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById(w.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'min-h-0 min-w-0 rounded-xl border p-2.5 text-left transition-colors sm:p-3',
|
||||||
|
active === w.id
|
||||||
|
? 'border-[#ccff00]/60 bg-[#ccff00]/10'
|
||||||
|
: 'border-white/10 bg-black/60 hover:border-[#ccff00]/40 hover:bg-white/5',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-2xl">{w.emoji}</span>
|
<span className="text-xl">{w.emoji}</span>
|
||||||
<p className="mt-2 text-sm font-black uppercase text-white">{w.title}</p>
|
<p className="mt-1 text-[10px] font-black uppercase leading-tight text-white sm:mt-2 sm:text-xs">{w.title}</p>
|
||||||
<p className="mt-1 text-xs text-gray-400">{w.desc}</p>
|
<p className="mt-0.5 hidden text-[10px] leading-snug text-gray-400 sm:block">{w.desc}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ZapDialog } from '@/components/ZapDialog';
|
import { LazyZapDialog } from '@/components/LazyZapDialog';
|
||||||
import { useZaps } from '@/hooks/useZaps';
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -65,7 +65,7 @@ export function ZapButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ZapDialog target={target}>
|
<LazyZapDialog target={target}>
|
||||||
<div className={`flex items-center gap-1 ${className}`}>
|
<div className={`flex items-center gap-1 ${className}`}>
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
|
|
@ -78,6 +78,6 @@ export function ZapButton({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ZapDialog>
|
</LazyZapDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef, forwardRef } from 'react';
|
import { useState, useEffect, useRef, forwardRef, useCallback } from 'react';
|
||||||
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X, Wallet } from 'lucide-react';
|
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X, Wallet } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -42,6 +42,11 @@ interface ZapDialogProps {
|
||||||
target: Event;
|
target: Event;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Controlled open state (used by LazyZapDialog). */
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
/** Skip the built-in trigger when the caller owns the trigger. */
|
||||||
|
hideTrigger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const presetAmounts = [
|
const presetAmounts = [
|
||||||
|
|
@ -420,9 +425,21 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
||||||
});
|
});
|
||||||
ZapContent.displayName = 'ZapContent';
|
ZapContent.displayName = 'ZapContent';
|
||||||
|
|
||||||
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
export function ZapDialog({
|
||||||
|
target,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
hideTrigger = false,
|
||||||
|
}: ZapDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = controlledOpen ?? internalOpen;
|
||||||
|
const setOpen = useCallback((next: boolean) => {
|
||||||
|
if (controlledOpen !== undefined) onOpenChange?.(next);
|
||||||
|
else setInternalOpen(next);
|
||||||
|
}, [controlledOpen, onOpenChange]);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data: author } = useAuthor(target.pubkey);
|
const { data: author } = useAuthor(target.pubkey);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -664,11 +681,13 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||||
shouldScaleBackground={false}
|
shouldScaleBackground={false}
|
||||||
fadeFromIndex={0}
|
fadeFromIndex={0}
|
||||||
>
|
>
|
||||||
|
{!hideTrigger && children != null && (
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<div className={`cursor-pointer ${className || ''}`}>
|
<div className={`cursor-pointer ${className || ''}`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
|
)}
|
||||||
<DrawerContent
|
<DrawerContent
|
||||||
key={invoice ? 'payment' : 'form'}
|
key={invoice ? 'payment' : 'form'}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -732,11 +751,13 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
{!hideTrigger && children != null && (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<div className={`cursor-pointer ${className || ''}`}>
|
<div className={`cursor-pointer ${className || ''}`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
|
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg break-words">
|
<DialogTitle className="text-base sm:text-lg break-words">
|
||||||
|
|
|
||||||
|
|
@ -814,6 +814,11 @@ export function CordnNativePanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
|
<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 && (
|
{!isCommunity && (
|
||||||
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
|
<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">
|
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
|
||||||
|
|
@ -870,20 +875,49 @@ export function CordnNativePanel({
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ol className="mx-auto max-w-sm space-y-2 text-left">
|
<ol className="mx-auto max-w-sm space-y-2 text-left">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => {
|
||||||
<li key={step} className="flex items-start gap-2 text-xs text-gray-400">
|
// Members see the setup move visibly: device key prepared →
|
||||||
<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">
|
// request sent → waiting for an admin. Founders start at step 1.
|
||||||
{index + 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>
|
||||||
<span>{step}</span>
|
<span>{step}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
|
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
|
||||||
{copy.encryptedNote}
|
{copy.encryptedNote}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Amber needs one foreground approval for the device key and one
|
||||||
|
for the join request. Show the two translated step labels so the
|
||||||
|
second prompt is expected, not a mystery. */}
|
||||||
|
{native.interactiveHintVisible && !canCreate && (
|
||||||
|
<p className="mx-auto flex max-w-sm items-center justify-center gap-1.5 text-[11px] text-amber-300/80">
|
||||||
|
<KeyRound className="h-3 w-3 shrink-0" />
|
||||||
|
<span>Amber</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{t('cordnUi.setupDevice')}</span>
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
<span>{t('cordnUi.requestJoin')}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
{canCreate ? (
|
{canCreate ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users } from 'lucide-react';
|
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users, QrCode } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -217,6 +219,7 @@ export function CrewCoordinatorCard({
|
||||||
function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays?: string[] } }) {
|
function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays?: string[] } }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [showQr, setShowQr] = useState(false);
|
||||||
const nprofile = coordinator.pubkey
|
const nprofile = coordinator.pubkey
|
||||||
? nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] })
|
? nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] })
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -247,6 +250,7 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
|
||||||
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openInCordnWeb')}
|
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openInCordnWeb')}
|
||||||
</a>
|
</a>
|
||||||
{nprofile && (
|
{nprofile && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copyNprofile()}
|
onClick={() => void copyNprofile()}
|
||||||
|
|
@ -254,6 +258,23 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
|
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowQr((v) => !v)}
|
||||||
|
aria-expanded={showQr}
|
||||||
|
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
|
||||||
|
>
|
||||||
|
<QrCode className="h-3.5 w-3.5" /> QR
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showQr && nprofile && (
|
||||||
|
<div className="mt-2 flex w-full flex-col items-center gap-2 rounded-xl border border-white/10 bg-black/40 p-3">
|
||||||
|
<QRCodeCanvas value={nprofile} size={168} level="M" />
|
||||||
|
<p className="text-center text-[11px] text-gray-500">
|
||||||
|
{t('socialUi.coordinator.scanToJoin')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,13 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams)
|
||||||
staleTime: interactive ? Infinity : 2000,
|
staleTime: interactive ? Infinity : 2000,
|
||||||
// Amber: decrypt the backlog once per session, not on every crew-page visit.
|
// Amber: decrypt the backlog once per session, not on every crew-page visit.
|
||||||
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
|
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
|
||||||
queryFn: async (): Promise<CordnMessage[]> => {
|
queryFn: async (c): Promise<CordnMessage[]> => {
|
||||||
if (!endpoint || !user) return [];
|
if (!endpoint || !user) return [];
|
||||||
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
|
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
|
||||||
const signer = user.signer as unknown as Nip44Signer;
|
const signer = user.signer as unknown as Nip44Signer;
|
||||||
const signal = AbortSignal.timeout(10000);
|
// Honour query cancellation too — the fetch loop decrypts per message and
|
||||||
|
// must not keep opening Amber after the crew page is closed.
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||||
return fetchGroupMessages({
|
return fetchGroupMessages({
|
||||||
relay,
|
relay,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostrLogin } from '@nostrify/react/login';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -25,6 +25,11 @@ export function useCordnJoinNotifications(opened = false) {
|
||||||
const interactive = isInteractiveSigner(user, logins);
|
const interactive = isInteractiveSigner(user, logins);
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const [dismissed, setDismissed] = useLocalStorage<string[]>(DISMISSED_KEY, []);
|
const [dismissed, setDismissed] = useLocalStorage<string[]>(DISMISSED_KEY, []);
|
||||||
|
// The bell can close while a decrypt loop is running; React Query stops
|
||||||
|
// scheduling new fetches when `enabled` flips, but does not necessarily abort
|
||||||
|
// the current queryFn. Read the live open state inside the loop.
|
||||||
|
const openedRef = useRef(opened);
|
||||||
|
openedRef.current = opened;
|
||||||
// Amber prompts once per decrypt. Load these only when the bell is opened;
|
// Amber prompts once per decrypt. Load these only when the bell is opened;
|
||||||
// passive signers can fetch them quietly in the background.
|
// passive signers can fetch them quietly in the background.
|
||||||
const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened);
|
const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened);
|
||||||
|
|
@ -51,6 +56,9 @@ export function useCordnJoinNotifications(opened = false) {
|
||||||
);
|
);
|
||||||
const notices: CordnJoinNotice[] = [];
|
const notices: CordnJoinNotice[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
// Closing the bell/unmounting the query must stop further Amber
|
||||||
|
// decrypt prompts; the old loop kept going after navigation.
|
||||||
|
if (!openedRef.current || c.signal.aborted) break;
|
||||||
let plain: string;
|
let plain: string;
|
||||||
try {
|
try {
|
||||||
plain = event.content.trim().startsWith('{')
|
plain = event.content.trim().startsWith('{')
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export interface UseCordnNativeResult {
|
||||||
/** Send a read receipt for an inbound message. */
|
/** Send a read receipt for an inbound message. */
|
||||||
markRead: (messageId: string) => Promise<boolean>;
|
markRead: (messageId: string) => Promise<boolean>;
|
||||||
/** Re-fetch messages (and adopt any new commits). */
|
/** Re-fetch messages (and adopt any new commits). */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<boolean>;
|
||||||
/** Founder/admin: create the crew's MLS group. */
|
/** Founder/admin: create the crew's MLS group. */
|
||||||
createChat: () => Promise<boolean>;
|
createChat: () => Promise<boolean>;
|
||||||
/** Non-admin: ask the coordinator to be added (publishes a key package). */
|
/** Non-admin: ask the coordinator to be added (publishes a key package). */
|
||||||
|
|
@ -110,6 +110,8 @@ export interface UseCordnNativeResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
const POLL_MS = 6000;
|
const POLL_MS = 6000;
|
||||||
|
const HIDDEN_POLL_MS = 12000;
|
||||||
|
const MAX_BACKOFF_MS = 60000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn
|
* Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn
|
||||||
|
|
@ -231,9 +233,9 @@ export function useCordnNative({
|
||||||
};
|
};
|
||||||
}, [enabled, pubkey, signer, coordinator, groupId, reloadKey]);
|
}, [enabled, pubkey, signer, coordinator, groupId, reloadKey]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async (): Promise<boolean> => {
|
||||||
const session = sessionRef.current;
|
const session = sessionRef.current;
|
||||||
if (!session || !group) return;
|
if (!session || !group) return false;
|
||||||
try {
|
try {
|
||||||
await session.fetch(group.gid);
|
await session.fetch(group.gid);
|
||||||
const next = session.messages(group.gid);
|
const next = session.messages(group.gid);
|
||||||
|
|
@ -241,22 +243,56 @@ export function useCordnNative({
|
||||||
setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group);
|
setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLastCheckedAt(Date.now());
|
setLastCheckedAt(Date.now());
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setLastCheckedAt(Date.now());
|
setLastCheckedAt(Date.now());
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [group]);
|
}, [group]);
|
||||||
|
|
||||||
// Live polling for silent signers only — an interactive signer would pop for
|
// Live polling for silent signers only — an interactive signer would pop for
|
||||||
// every request, so those users refresh/send manually (same rule as the DM
|
// every request, so those users refresh/send manually (same rule as the DM
|
||||||
// and sealed-relay chat hooks).
|
// and sealed-relay chat hooks). Hidden tabs poll less, and a failure backs
|
||||||
|
// off exponentially with jitter so a busy/rate-limited coordinator is not
|
||||||
|
// hammered by every open device.
|
||||||
|
const pollDelayRef = useRef(POLL_MS);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || !group || interactive || !canBackgroundPoll) return;
|
if (!ready || !group || interactive || !canBackgroundPoll) return;
|
||||||
const id = setInterval(() => void refresh(), POLL_MS);
|
let cancelled = false;
|
||||||
return () => clearInterval(id);
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
}, [ready, group, interactive, canBackgroundPoll, refresh]);
|
pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
const ok = await refresh();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (ok) {
|
||||||
|
pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS;
|
||||||
|
} else {
|
||||||
|
pollDelayRef.current = Math.min(
|
||||||
|
MAX_BACKOFF_MS,
|
||||||
|
Math.max(POLL_MS, Math.round(pollDelayRef.current * 1.7)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const jitter = Math.round(Math.random() * 800);
|
||||||
|
timer = setTimeout(() => void tick(), pollDelayRef.current + jitter);
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => void tick(), pollDelayRef.current);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [ready, group, interactive, canBackgroundPoll, active, refresh]);
|
||||||
|
|
||||||
|
// One coordinator operation at a time. With Amber every coordinator request
|
||||||
|
// is a foreground approval; a double-tap on "Set up" used to start two
|
||||||
|
// publish/join flows, which looked like Amber randomly opening twice and left
|
||||||
|
// one of the two requests to time out.
|
||||||
|
const runInFlight = useRef(false);
|
||||||
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
|
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
|
||||||
|
if (runInFlight.current) return undefined;
|
||||||
|
runInFlight.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -265,6 +301,7 @@ export function useCordnNative({
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
|
runInFlight.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setLastCheckedAt(Date.now());
|
setLastCheckedAt(Date.now());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,21 @@ type DecryptedDm = CachedDm;
|
||||||
const DM_DECRYPT_CACHE_MAX = 4000;
|
const DM_DECRYPT_CACHE_MAX = 4000;
|
||||||
const dmDecryptCache = new Map<string, DecryptedDm>();
|
const dmDecryptCache = new Map<string, DecryptedDm>();
|
||||||
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
|
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
|
||||||
|
/**
|
||||||
|
* Seal plaintext cache. A gift wrap needs TWO decrypts (seal, then rumor). If
|
||||||
|
* the user navigates while Amber is open after the first one, the query aborts
|
||||||
|
* before we can build the full message. Keeping the seal means the next visit
|
||||||
|
* only asks Amber for the rumor instead of prompting twice for the same wrap.
|
||||||
|
*/
|
||||||
|
const dmSealCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function rememberSeal(key: string, sealJson: string): void {
|
||||||
|
if (dmSealCache.size >= DM_DECRYPT_CACHE_MAX) {
|
||||||
|
const oldest = dmSealCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) dmSealCache.delete(oldest);
|
||||||
|
}
|
||||||
|
dmSealCache.set(key, sealJson);
|
||||||
|
}
|
||||||
|
|
||||||
// Which accounts' persisted caches have been loaded into memory this session,
|
// Which accounts' persisted caches have been loaded into memory this session,
|
||||||
// and any hydration in flight (so concurrent readers await the same load).
|
// and any hydration in flight (so concurrent readers await the same load).
|
||||||
|
|
@ -286,11 +301,17 @@ async function decryptGiftWrap(
|
||||||
viewerPk: string,
|
viewerPk: string,
|
||||||
signer: DmSigner,
|
signer: DmSigner,
|
||||||
wrap: NostrEvent,
|
wrap: NostrEvent,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<DecryptedDm | null> {
|
): Promise<DecryptedDm | null> {
|
||||||
|
// A cancelled query must stop launching NEW Amber intents. Without this, a
|
||||||
|
// user who starts decrypting the inbox and then navigates away would keep
|
||||||
|
// getting Amber popups for every remaining gift wrap in the background.
|
||||||
|
if (signal?.aborted) return null;
|
||||||
const key = `${viewerPk}:${wrap.id}`;
|
const key = `${viewerPk}:${wrap.id}`;
|
||||||
// Hydrate the persisted cache once per account before the first lookup, so a
|
// Hydrate the persisted cache once per account before the first lookup, so a
|
||||||
// relaunch reuses decryptions that already happened on this device.
|
// relaunch reuses decryptions that already happened on this device.
|
||||||
await ensureDmCacheHydrated(viewerPk);
|
await ensureDmCacheHydrated(viewerPk);
|
||||||
|
if (signal?.aborted) return null;
|
||||||
const cached = dmDecryptCache.get(key);
|
const cached = dmDecryptCache.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const flying = dmDecryptInFlight.get(key);
|
const flying = dmDecryptInFlight.get(key);
|
||||||
|
|
@ -298,7 +319,15 @@ async function decryptGiftWrap(
|
||||||
|
|
||||||
const task = (async (): Promise<DecryptedDm | null> => {
|
const task = (async (): Promise<DecryptedDm | null> => {
|
||||||
try {
|
try {
|
||||||
const sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content);
|
let sealJson = dmSealCache.get(key);
|
||||||
|
if (!sealJson) {
|
||||||
|
sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content);
|
||||||
|
rememberSeal(key, sealJson);
|
||||||
|
}
|
||||||
|
// Navigation may have cancelled the query while Amber was open. Do not
|
||||||
|
// ask for the second decrypt (which would pop Amber again) — the next
|
||||||
|
// explicit visit reuses the cached seal and only asks for the rumor.
|
||||||
|
if (signal?.aborted) return null;
|
||||||
const seal = JSON.parse(sealJson) as NostrEvent;
|
const seal = JSON.parse(sealJson) as NostrEvent;
|
||||||
if (seal.kind !== SEAL_KIND) return null;
|
if (seal.kind !== SEAL_KIND) return null;
|
||||||
// SECURITY: verify the seal's signature (sender impersonation).
|
// SECURITY: verify the seal's signature (sender impersonation).
|
||||||
|
|
@ -326,6 +355,7 @@ async function decryptGiftWrap(
|
||||||
if (oldest !== undefined) dmDecryptCache.delete(oldest);
|
if (oldest !== undefined) dmDecryptCache.delete(oldest);
|
||||||
}
|
}
|
||||||
dmDecryptCache.set(key, dm);
|
dmDecryptCache.set(key, dm);
|
||||||
|
dmSealCache.delete(key);
|
||||||
// Persist so this wrap is never decrypted again (fire-and-forget).
|
// Persist so this wrap is never decrypted again (fire-and-forget).
|
||||||
void saveDmDecryptKey(key, viewerPk, dm).catch(() => {});
|
void saveDmDecryptKey(key, viewerPk, dm).catch(() => {});
|
||||||
return dm;
|
return dm;
|
||||||
|
|
@ -661,10 +691,11 @@ export function useConversation(otherPubkey: string) {
|
||||||
const messages: DirectMessage[] = [];
|
const messages: DirectMessage[] = [];
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
// Decrypt via the shared session cache (coalesced; one decrypt per wrap
|
// Decrypt via the shared session cache (coalesced; one decrypt per wrap
|
||||||
// per session even across different views).
|
// per session even across different views).
|
||||||
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
|
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
|
||||||
if (!dm) continue;
|
if (!dm) continue;
|
||||||
|
|
||||||
// 1:1 only. A message with more than one recipient is a GROUP message
|
// 1:1 only. A message with more than one recipient is a GROUP message
|
||||||
|
|
@ -750,9 +781,10 @@ export function useConversations() {
|
||||||
let undecryptable = 0;
|
let undecryptable = 0;
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
// Decrypt via the shared session cache.
|
// Decrypt via the shared session cache.
|
||||||
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
|
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
|
||||||
if (!dm) continue;
|
if (!dm) continue;
|
||||||
|
|
||||||
// 1:1 list only. A message with more than one recipient is a GROUP
|
// 1:1 list only. A message with more than one recipient is a GROUP
|
||||||
|
|
@ -864,8 +896,9 @@ export function useGroupConversations() {
|
||||||
const byGroup = new Map<string, DirectMessage[]>();
|
const byGroup = new Map<string, DirectMessage[]>();
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
|
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
|
||||||
if (!dm) continue;
|
if (!dm) continue;
|
||||||
// 1:1 messages are surfaced by useConversations, not here.
|
// 1:1 messages are surfaced by useConversations, not here.
|
||||||
if (dm.recipients.length <= 1) continue;
|
if (dm.recipients.length <= 1) continue;
|
||||||
|
|
@ -975,8 +1008,9 @@ export function useGroupConversation(participants: string[]) {
|
||||||
const messages: DirectMessage[] = [];
|
const messages: DirectMessage[] = [];
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
|
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
|
||||||
if (!dm) continue;
|
if (!dm) continue;
|
||||||
|
|
||||||
// Only include if this message is part of the target group.
|
// Only include if this message is part of the target group.
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ import { usePreferences } from '@/hooks/usePreferences';
|
||||||
* Bottom-nav customization (mobile).
|
* Bottom-nav customization (mobile).
|
||||||
*
|
*
|
||||||
* Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a
|
* Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a
|
||||||
* FIXED Profile/Login slot on the right. The user customizes the **3 middle
|
* FIXED More button on the right. The user customizes the **3 middle slots**
|
||||||
* slots** from a curated catalog. Anything they don't pin stays reachable in the
|
* from a curated catalog. Profile/login lives in the header avatar menu. Anything they don't pin stays reachable in the
|
||||||
* burger menu, so nothing is ever lost — just re-prioritized.
|
* burger menu, so nothing is ever lost — just re-prioritized.
|
||||||
*
|
*
|
||||||
* Stored per-device in localStorage (v1). Could later sync across devices via a
|
* Stored per-device in localStorage (v1). Could later sync across devices via a
|
||||||
|
|
@ -55,7 +55,7 @@ export const FIXED_MAP_TAB: NavTab = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the user can pin to the 3 middle slots.
|
* Everything the user can pin to the 3 middle slots.
|
||||||
* (Map is excluded — it's the fixed anchor. Profile is the fixed right slot.)
|
* (Map is excluded — it's the fixed anchor. More is the fixed right slot.)
|
||||||
*/
|
*/
|
||||||
export const NAV_CATALOG: NavTab[] = [
|
export const NAV_CATALOG: NavTab[] = [
|
||||||
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
|
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
|
||||||
|
|
@ -75,11 +75,12 @@ export const NAV_CATALOG: NavTab[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Number of user-customizable middle slots. */
|
/** Number of user-customizable middle slots. */
|
||||||
export const MIDDLE_SLOTS = 4;
|
export const MIDDLE_SLOTS = 3;
|
||||||
|
|
||||||
/** Default middle tabs. Includes Mints (Cashu mint directory) so it's reachable
|
/** Default middle tabs. Keep the mobile bar to five intents total:
|
||||||
* from the phone nav out of the box; users can swap any of them via long-press. */
|
* fixed Map + these three + a fixed More button. Everything else lives in the
|
||||||
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews', 'mints'];
|
* burger menu, and users can still swap any middle tab via long-press. */
|
||||||
|
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
|
||||||
|
|
||||||
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
|
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
|
||||||
|
|
||||||
|
|
@ -90,7 +91,7 @@ const STORAGE_KEY = 'skatespots:nav-tabs:v1';
|
||||||
// ever lost. Map is always the first primary tab and cannot be hidden/moved.
|
// ever lost. Map is always the first primary tab and cannot be hidden/moved.
|
||||||
|
|
||||||
/** Default desktop primary tabs. */
|
/** Default desktop primary tabs. */
|
||||||
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'mints'];
|
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'community'];
|
||||||
|
|
||||||
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';
|
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';
|
||||||
|
|
||||||
|
|
|
||||||
62
src/hooks/useRelayDivergence.ts
Normal file
62
src/hooks/useRelayDivergence.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useRelays } from '@/lib/relays';
|
||||||
|
import { SKATE_RELAY } from '@/lib/types';
|
||||||
|
import { onlyVerified } from '@/lib/verifyEvents';
|
||||||
|
import {
|
||||||
|
compareRelaySnapshots,
|
||||||
|
type RelayDivergenceResult,
|
||||||
|
type RelaySnapshot,
|
||||||
|
} from '@/lib/relayDivergence';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-demand relay consistency diagnostic.
|
||||||
|
*
|
||||||
|
* Queries a bounded profile snapshot from the community relay and the user's
|
||||||
|
* read relays, then reports newer/different signed profiles. This is not a vote
|
||||||
|
* and it never mutates canonical map/community data.
|
||||||
|
*/
|
||||||
|
export function useRelayDivergence() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { read } = useRelays();
|
||||||
|
const [result, setResult] = useState<RelayDivergenceResult | null>(null);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const check = useCallback(async () => {
|
||||||
|
setChecking(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const relays = Array.from(new Set([SKATE_RELAY, ...read])).slice(0, 5);
|
||||||
|
const signal = AbortSignal.timeout(12_000);
|
||||||
|
|
||||||
|
const snapshots = await Promise.all(
|
||||||
|
relays.map(async (relayUrl): Promise<RelaySnapshot> => {
|
||||||
|
try {
|
||||||
|
const relay = nostr.relay(relayUrl);
|
||||||
|
const events = await relay.query([{ kinds: [0], limit: 100 }], { signal });
|
||||||
|
return { relay: relayUrl, events: onlyVerified(events) };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
relay: relayUrl,
|
||||||
|
events: [],
|
||||||
|
error: e instanceof Error ? e.message : 'relay error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canonical = snapshots.find((s) => s.relay === SKATE_RELAY);
|
||||||
|
if (!canonical) throw new Error('Community relay snapshot missing.');
|
||||||
|
const others = snapshots.filter((s) => s.relay !== SKATE_RELAY);
|
||||||
|
setResult(compareRelaySnapshots(canonical, others));
|
||||||
|
} catch (e) {
|
||||||
|
setResult(null);
|
||||||
|
setError(e instanceof Error ? e.message : 'check failed');
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}, [nostr, read]);
|
||||||
|
|
||||||
|
return { result, checking, error, check };
|
||||||
|
}
|
||||||
|
|
@ -4,30 +4,27 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||||
import { useRelays } from '@/lib/relays';
|
import { useRelays } from '@/lib/relays';
|
||||||
import { useCitrine } from '@/hooks/useCitrine';
|
import { useCitrine } from '@/hooks/useCitrine';
|
||||||
import { SKATE_RELAY } from '@/lib/types';
|
import { SKATE_RELAY } from '@/lib/types';
|
||||||
|
import { mergeEvents } from '@/lib/eventMerge';
|
||||||
|
import { getLocalDeletions } from '@/lib/localDeletions';
|
||||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicate a batch of events from several relays, keeping the newest per
|
* Merge relay results deterministically and drop this device's local deletion
|
||||||
* identity:
|
* tombstones. Consensus is deliberately absent: relays are not voters, and this
|
||||||
* - addressable (has `d`): `pubkey:kind:d-tag`;
|
* client must not let relay timing decide which version of an event is shown.
|
||||||
* - replaceable (0, 3, 10000–19999): `pubkey:kind` (NIP-01 keeps only the
|
|
||||||
* latest per author+kind — keying by id would surface stale profiles/relay
|
|
||||||
* lists when relays return different versions);
|
|
||||||
* - otherwise: the event id.
|
|
||||||
*/
|
*/
|
||||||
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
|
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
|
||||||
const map = new Map<string, NostrEvent>();
|
const deleted = getLocalDeletions();
|
||||||
for (const event of events) {
|
return mergeEvents(events, {
|
||||||
|
isDeleted: (event) => {
|
||||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
let key: string;
|
const coord = dTag !== undefined ? `${event.kind}:${event.pubkey}:${dTag}` : undefined;
|
||||||
if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`;
|
return !!(
|
||||||
else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
(coord && deleted.has(coord)) ||
|
||||||
key = `${event.pubkey}:${event.kind}`;
|
deleted.has(`e:${event.id}`)
|
||||||
} else key = event.id;
|
);
|
||||||
const existing = map.get(key);
|
},
|
||||||
if (!existing || event.created_at > existing.created_at) map.set(key, event);
|
});
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ export interface TradeReputation {
|
||||||
purchases: number;
|
purchases: number;
|
||||||
/** Deals where both sides attested the same listing. */
|
/** Deals where both sides attested the same listing. */
|
||||||
verified: number;
|
verified: number;
|
||||||
|
/** Mutually confirmed deals with an attestation in the last 90 days. */
|
||||||
|
recentVerified: number;
|
||||||
/**
|
/**
|
||||||
* Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim).
|
* Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim).
|
||||||
* ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a
|
* ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a
|
||||||
|
|
@ -222,7 +224,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
enabled: !!pubkey,
|
enabled: !!pubkey,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async (c): Promise<TradeReputation> => {
|
queryFn: async (c): Promise<TradeReputation> => {
|
||||||
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] };
|
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, recentVerified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] };
|
||||||
if (!pubkey) return empty;
|
if (!pubkey) return empty;
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
||||||
|
|
@ -256,7 +258,9 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
let sales = 0;
|
let sales = 0;
|
||||||
let purchases = 0;
|
let purchases = 0;
|
||||||
let verified = 0;
|
let verified = 0;
|
||||||
|
let recentVerified = 0;
|
||||||
let withPaymentProof = 0;
|
let withPaymentProof = 0;
|
||||||
|
const recentCutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||||
|
|
||||||
for (const [key, attestations] of byListing) {
|
for (const [key, attestations] of byListing) {
|
||||||
const coord = parseListingCoord(key);
|
const coord = parseListingCoord(key);
|
||||||
|
|
@ -271,6 +275,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
else purchases++;
|
else purchases++;
|
||||||
if (isVerified) {
|
if (isVerified) {
|
||||||
verified++;
|
verified++;
|
||||||
|
if (Math.max(...attestations.map((e) => e.created_at)) >= recentCutoff) recentVerified++;
|
||||||
// Attribute the verified trade to the other attestor(s).
|
// Attribute the verified trade to the other attestor(s).
|
||||||
for (const a of attestors) {
|
for (const a of attestors) {
|
||||||
if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1);
|
if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1);
|
||||||
|
|
@ -329,6 +334,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
sales,
|
sales,
|
||||||
purchases,
|
purchases,
|
||||||
verified,
|
verified,
|
||||||
|
recentVerified,
|
||||||
withPaymentProof,
|
withPaymentProof,
|
||||||
paymentVerified,
|
paymentVerified,
|
||||||
verifiedCounterparties: verifiedWith.size,
|
verifiedCounterparties: verifiedWith.size,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,73 @@ import { useCurrentUser } from "./useCurrentUser";
|
||||||
* mirror URL on the returned tags so photo redundancy is explicit in the event;
|
* mirror URL on the returned tags so photo redundancy is explicit in the event;
|
||||||
* the read side also derives it from the sha256 (lib/imageFallback).
|
* the read side also derives it from the sha256 (lib/imageFallback).
|
||||||
*/
|
*/
|
||||||
const UPLOAD_SERVERS = ['https://blossom.skate-spots.org/', 'https://blossom.primal.net/', 'https://blossom.band/'];
|
// Uploads go only through our own Blossom/quota proxy. The public mirrors are
|
||||||
|
// still used by the read-side image fallback for older blobs, but including them
|
||||||
|
// here would let a quota rejection silently fall through to third-party hosting.
|
||||||
|
const UPLOAD_SERVERS = ['https://blossom.skate-spots.org/'];
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const IMAGE_TYPES = new Set([
|
||||||
|
'image/jpeg', 'image/png', 'image/webp', 'image/avif',
|
||||||
|
'image/heic', 'image/heif', 'image/gif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Unwrap the quota proxy's JSON error so forms can show a human message. */
|
||||||
|
function uploadErrorMessage(error: unknown): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const match = /Blossom upload failed \(\d+\):\s*(\{.*\})/.exec(message);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[1]) as { error?: string };
|
||||||
|
if (parsed.error) return parsed.error;
|
||||||
|
} catch {
|
||||||
|
// fall through to the original message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImage(file: File): void {
|
||||||
|
const type = (file.type || '').toLowerCase();
|
||||||
|
if (!IMAGE_TYPES.has(type)) {
|
||||||
|
throw new Error('Only JPEG, PNG, WebP, AVIF, HEIC or GIF images can be uploaded.');
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error('Image is too large. The maximum is 10 MB.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize/compress camera photos before upload. Most phone photos are far larger
|
||||||
|
* than the app ever displays; 1600px WebP cuts upload time and Blossom storage
|
||||||
|
* without touching GIFs or formats the WebView cannot decode.
|
||||||
|
*/
|
||||||
|
async function prepareImage(file: File): Promise<File> {
|
||||||
|
const type = (file.type || '').toLowerCase();
|
||||||
|
if (type === 'image/gif' || type === 'image/heic' || type === 'image/heif') return file;
|
||||||
|
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
|
||||||
|
// Always redraw through a canvas, even for images that are already small:
|
||||||
|
// this strips EXIF/GPS and other metadata before the file leaves the device.
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) { bitmap.close(); return file; }
|
||||||
|
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||||
|
bitmap.close();
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/webp', 0.84);
|
||||||
|
});
|
||||||
|
if (!blob) return file;
|
||||||
|
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
|
||||||
|
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
|
||||||
|
} catch {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hostOf(url: string): string {
|
function hostOf(url: string): string {
|
||||||
try {
|
try {
|
||||||
|
|
@ -30,12 +96,20 @@ export function useUploadFile() {
|
||||||
throw new Error('Must be logged in to upload files');
|
throw new Error('Must be logged in to upload files');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateImage(file);
|
||||||
|
const prepared = await prepareImage(file);
|
||||||
|
|
||||||
const uploader = new BlossomUploader({
|
const uploader = new BlossomUploader({
|
||||||
servers: UPLOAD_SERVERS,
|
servers: UPLOAD_SERVERS,
|
||||||
signer: user.signer,
|
signer: user.signer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tags = await uploader.upload(file);
|
let tags;
|
||||||
|
try {
|
||||||
|
tags = await uploader.upload(prepared);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(uploadErrorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
// Remember the mirror copy of the same blob. Blossom is content-addressed,
|
// Remember the mirror copy of the same blob. Blossom is content-addressed,
|
||||||
// so `<other-host>/<sha256>` is the same file.
|
// so `<other-host>/<sha256>` is the same file.
|
||||||
|
|
|
||||||
|
|
@ -1959,6 +1959,7 @@ marketUi: {
|
||||||
newTrader: 'New trader',
|
newTrader: 'New trader',
|
||||||
title: 'Trades',
|
title: 'Trades',
|
||||||
verified: 'Verified',
|
verified: 'Verified',
|
||||||
|
recentConfirmed: 'confirmed in the last 90 days',
|
||||||
sales: 'Sales',
|
sales: 'Sales',
|
||||||
purchases: 'Purchases',
|
purchases: 'Purchases',
|
||||||
tradeConfirmedOne: 'mutually-confirmed trade',
|
tradeConfirmedOne: 'mutually-confirmed trade',
|
||||||
|
|
@ -5018,6 +5019,14 @@ settingsUi: {
|
||||||
localDesc: 'App-only (e.g. a LAN node). Used for reads and writes; never published.',
|
localDesc: 'App-only (e.g. a LAN node). Used for reads and writes; never published.',
|
||||||
blockedTitle: 'Blocked relays',
|
blockedTitle: 'Blocked relays',
|
||||||
blockedDesc: 'Never connect to these — excluded from every read and write.',
|
blockedDesc: 'Never connect to these — excluded from every read and write.',
|
||||||
|
relayDiagTitle: 'Relay consistency',
|
||||||
|
relayDiagDesc: 'Compare the community relay with your own read relays. This is a diagnostic, not a vote.',
|
||||||
|
relayDiagCheck: 'Check profiles',
|
||||||
|
relayDiagChecking: 'Checking…',
|
||||||
|
relayDiagAgree: 'Relays agree',
|
||||||
|
relayDiagDiff: '{{count}} differences',
|
||||||
|
relayDiagFailed: 'Could not check relays.',
|
||||||
|
relayDiagDetails: 'A newer or different profile exists on one relay. No data was changed.',
|
||||||
},
|
},
|
||||||
relatr: {
|
relatr: {
|
||||||
searchingTitle: 'Searching for Relatr service...',
|
searchingTitle: 'Searching for Relatr service...',
|
||||||
|
|
|
||||||
|
|
@ -1959,6 +1959,7 @@ marketUi: {
|
||||||
newTrader: 'Comerciante nuevo',
|
newTrader: 'Comerciante nuevo',
|
||||||
title: 'Tratos',
|
title: 'Tratos',
|
||||||
verified: 'Verificados',
|
verified: 'Verificados',
|
||||||
|
recentConfirmed: 'confirmados en los últimos 90 días',
|
||||||
sales: 'Ventas',
|
sales: 'Ventas',
|
||||||
purchases: 'Compras',
|
purchases: 'Compras',
|
||||||
tradeConfirmedOne: 'trato confirmado mutuamente',
|
tradeConfirmedOne: 'trato confirmado mutuamente',
|
||||||
|
|
@ -5018,6 +5019,14 @@ settingsUi: {
|
||||||
localDesc: 'Solo de la app (p. ej. un nodo de LAN). Se usan para lectura y escritura; nunca se publican.',
|
localDesc: 'Solo de la app (p. ej. un nodo de LAN). Se usan para lectura y escritura; nunca se publican.',
|
||||||
blockedTitle: 'Relays bloqueados',
|
blockedTitle: 'Relays bloqueados',
|
||||||
blockedDesc: 'No te conectes nunca a estos: se excluyen de toda lectura y escritura.',
|
blockedDesc: 'No te conectes nunca a estos: se excluyen de toda lectura y escritura.',
|
||||||
|
relayDiagTitle: 'Consistencia de relays',
|
||||||
|
relayDiagDesc: 'Compara el relay comunitario con tus relays de lectura. Es un diagnóstico, no una votación.',
|
||||||
|
relayDiagCheck: 'Comprobar perfiles',
|
||||||
|
relayDiagChecking: 'Comprobando…',
|
||||||
|
relayDiagAgree: 'Los relays coinciden',
|
||||||
|
relayDiagDiff: '{{count}} diferencias',
|
||||||
|
relayDiagFailed: 'No se pudieron comprobar los relays.',
|
||||||
|
relayDiagDetails: 'Hay un perfil más nuevo o distinto en otro relay. No se cambió ningún dato.',
|
||||||
},
|
},
|
||||||
relatr: {
|
relatr: {
|
||||||
searchingTitle: 'Buscando el servicio Relatr...',
|
searchingTitle: 'Buscando el servicio Relatr...',
|
||||||
|
|
|
||||||
|
|
@ -337,20 +337,14 @@
|
||||||
0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); }
|
0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); }
|
||||||
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
|
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
|
||||||
}
|
}
|
||||||
@keyframes splashSpin {
|
@keyframes splashLogoHandoff {
|
||||||
to { transform: rotate(360deg); }
|
0% { opacity: 1; transform: translateY(56px) scale(0.96); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
}
|
||||||
@keyframes splashLogoRollIn {
|
@keyframes splashLogoDrop {
|
||||||
0% { opacity: 0; transform: translateX(-58px) rotate(-210deg) scale(0.82); }
|
0% { opacity: 0; transform: translateY(-14px) scale(0.92); }
|
||||||
62% { opacity: 1; transform: translateX(5px) rotate(14deg) scale(1.03); }
|
62% { opacity: 1; transform: translateY(2px) scale(1.02); }
|
||||||
100% { opacity: 1; transform: translateX(0) rotate(0deg) scale(1); }
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
|
||||||
@keyframes splashWheelRoll {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
@keyframes splashHover {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-4px); }
|
|
||||||
}
|
}
|
||||||
@keyframes splashDust {
|
@keyframes splashDust {
|
||||||
0%, 100% { opacity: 0.28; transform: translateX(-50%) scaleX(1); }
|
0%, 100% { opacity: 0.28; transform: translateX(-50%) scaleX(1); }
|
||||||
|
|
@ -370,7 +364,7 @@
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: #ffb347;
|
background: #ffb347;
|
||||||
box-shadow: 0 0 10px 3px rgba(255, 106, 0, 0.85);
|
box-shadow: 0 0 10px 3px rgba(255, 106, 0, 0.85);
|
||||||
animation: splashSpark 1.15s ease-out infinite;
|
animation: splashSpark 560ms ease-out 1 both;
|
||||||
}
|
}
|
||||||
.splash-spark:nth-child(3n) {
|
.splash-spark:nth-child(3n) {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
|
|
@ -976,3 +970,10 @@ select {
|
||||||
:lang(zh) .tracking-wide {
|
:lang(zh) .tracking-wide {
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Delayed fade for the in-layout route skeleton. Route chunks that resolve
|
||||||
|
quickly never show a flash; slow ones fade in under the persistent chrome. */
|
||||||
|
@keyframes pageFallbackIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,31 @@ let started = false;
|
||||||
export function registerAppServiceWorker(): void {
|
export function registerAppServiceWorker(): void {
|
||||||
if (started || typeof window === 'undefined' || !('serviceWorker' in navigator)) return;
|
if (started || typeof window === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
if (Capacitor.isNativePlatform()) {
|
if (Capacitor.isNativePlatform()) {
|
||||||
// The native shell must never inherit an old web app-shell worker.
|
// The native shell must never inherit an old web app-shell worker. Some
|
||||||
if ('serviceWorker' in navigator) {
|
// Android WebViews expose a partial/absent Service Worker API, so every
|
||||||
|
// call is feature-checked and wrapped — this cleanup must never abort React
|
||||||
|
// boot (that produced a black screen after the native splash).
|
||||||
|
try {
|
||||||
|
if (navigator.serviceWorker && typeof navigator.serviceWorker.getRegistrations === 'function') {
|
||||||
void navigator.serviceWorker.getRegistrations()
|
void navigator.serviceWorker.getRegistrations()
|
||||||
.then((regs) => regs.forEach((reg) => { try { reg.unregister(); } catch { /* ignore */ } }))
|
.then((regs) => regs.forEach((reg) => { try { reg.unregister(); } catch { /* ignore */ } }))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
if (typeof caches !== 'undefined') {
|
if (typeof caches !== 'undefined' && typeof caches.keys === 'function') {
|
||||||
void caches.keys()
|
void caches.keys()
|
||||||
.then((keys) => keys.filter((key) => key.startsWith('skate-spots-shell-'))
|
.then((keys) => keys.filter((key) => key.startsWith('skate-spots-shell-'))
|
||||||
.forEach((key) => { void caches.delete(key).catch(() => undefined); }))
|
.forEach((key) => { void caches.delete(key).catch(() => undefined); }))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort only; boot must continue.
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
started = true;
|
started = true;
|
||||||
|
|
||||||
const register = () => {
|
const register = () => {
|
||||||
navigator.serviceWorker.register('/push-sw.js').catch(() => {
|
navigator.serviceWorker.register('/push-sw.js', { updateViaCache: 'none' }).catch(() => {
|
||||||
// Private mode / unsupported browser — the app works without it.
|
// Private mode / unsupported browser — the app works without it.
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,20 @@ function getNativeSigner(): AmberIntentNative | null {
|
||||||
return nativePlugin;
|
return nativePlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amber is a single foreground Activity. Two concurrent intents can race: one
|
||||||
|
* is answered, the others sit until the 60s native timeout, and the user sees
|
||||||
|
* Amber apparently open twice / behave unpredictably. Every native call goes
|
||||||
|
* through this one-at-a-time queue. The same request is also coalesced below,
|
||||||
|
* so duplicate hook work never asks Amber twice for the same thing.
|
||||||
|
*/
|
||||||
|
let amberTail: Promise<void> = Promise.resolve();
|
||||||
|
function runAmber<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const run = amberTail.then(operation, operation);
|
||||||
|
amberTail = run.then(() => undefined, () => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
/** Turn Amber's "rejected" rejection into a friendly, catchable message. */
|
/** Turn Amber's "rejected" rejection into a friendly, catchable message. */
|
||||||
function normalizeAmberError(e: unknown): Error {
|
function normalizeAmberError(e: unknown): Error {
|
||||||
const msg = (e as Error)?.message ?? String(e);
|
const msg = (e as Error)?.message ?? String(e);
|
||||||
|
|
@ -94,7 +108,12 @@ async function decodeAmberResult(value: string): Promise<string> {
|
||||||
* pre-authorize all the permissions the app needs so Amber can (optionally)
|
* pre-authorize all the permissions the app needs so Amber can (optionally)
|
||||||
* remember the user's choice for a smoother, popup-light experience afterwards.
|
* remember the user's choice for a smoother, popup-light experience afterwards.
|
||||||
*/
|
*/
|
||||||
export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
let publicKeyInFlight: Promise<string> | null = null;
|
||||||
|
|
||||||
|
export function getAmberPublicKeyViaPlugin(): Promise<string> {
|
||||||
|
if (publicKeyInFlight) return publicKeyInFlight;
|
||||||
|
|
||||||
|
const task = (async (): Promise<string> => {
|
||||||
const plugin = getNativeSigner();
|
const plugin = getNativeSigner();
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
||||||
|
|
@ -111,7 +130,7 @@ export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
||||||
|
|
||||||
let result: { result?: string; package?: string };
|
let result: { result?: string; package?: string };
|
||||||
try {
|
try {
|
||||||
result = await plugin.getPublicKey({ permissions });
|
result = await runAmber(() => plugin.getPublicKey({ permissions }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw normalizeAmberError(e);
|
throw normalizeAmberError(e);
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +152,33 @@ export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unexpected Amber response: "${value.slice(0, 30)}"`);
|
throw new Error(`Unexpected Amber response: "${value.slice(0, 30)}"`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
publicKeyInFlight = task;
|
||||||
|
void task.finally(() => {
|
||||||
|
if (publicKeyInFlight === task) publicKeyInFlight = null;
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signInFlight = new Map<string, Promise<{ result?: string; event?: string; id?: string }>>();
|
||||||
|
const cryptoInFlight = new Map<string, Promise<string>>();
|
||||||
|
/**
|
||||||
|
* Decrypt-only result cache. Decryption is deterministic for a given
|
||||||
|
* (identity, sender, ciphertext), so a re-render or a second screen can reuse
|
||||||
|
* the plaintext without making Amber ask again. Encrypt results are NOT cached
|
||||||
|
* (NIP-44 encryption is randomized). Memory-only and bounded; encrypted/private
|
||||||
|
* content is also cached by the feature hooks themselves.
|
||||||
|
*/
|
||||||
|
const DECRYPT_CACHE_MAX = 600;
|
||||||
|
const decryptCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function rememberDecrypt(key: string, value: string): void {
|
||||||
|
if (decryptCache.size >= DECRYPT_CACHE_MAX) {
|
||||||
|
const oldest = decryptCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) decryptCache.delete(oldest);
|
||||||
|
}
|
||||||
|
decryptCache.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CapacitorAmberSigner implements NostrSigner {
|
export class CapacitorAmberSigner implements NostrSigner {
|
||||||
|
|
@ -159,12 +205,25 @@ export class CapacitorAmberSigner implements NostrSigner {
|
||||||
|
|
||||||
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
||||||
const plugin = this.plugin();
|
const plugin = this.plugin();
|
||||||
let res: { result?: string; event?: string };
|
const eventJson = JSON.stringify(event);
|
||||||
|
const requestKey = `${this.npub}:${eventJson}`;
|
||||||
|
let res: { result?: string; event?: string; id?: string };
|
||||||
try {
|
try {
|
||||||
res = await plugin.signEvent({
|
const existing = signInFlight.get(requestKey);
|
||||||
eventJson: JSON.stringify(event),
|
if (existing) {
|
||||||
|
res = await existing;
|
||||||
|
} else {
|
||||||
|
const task = runAmber(() => plugin.signEvent({
|
||||||
|
eventJson,
|
||||||
npub: this.npub,
|
npub: this.npub,
|
||||||
});
|
}));
|
||||||
|
signInFlight.set(requestKey, task);
|
||||||
|
try {
|
||||||
|
res = await task;
|
||||||
|
} finally {
|
||||||
|
signInFlight.delete(requestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw normalizeAmberError(e);
|
throw normalizeAmberError(e);
|
||||||
}
|
}
|
||||||
|
|
@ -189,34 +248,61 @@ export class CapacitorAmberSigner implements NostrSigner {
|
||||||
return signed;
|
return signed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue + coalesce one crypto operation. A second identical decrypt/encrypt
|
||||||
|
* while the first is open waits on the same native result instead of opening
|
||||||
|
* a second Amber screen.
|
||||||
|
*/
|
||||||
|
private async crypto(
|
||||||
|
type: 'nip04_encrypt' | 'nip04_decrypt' | 'nip44_encrypt' | 'nip44_decrypt',
|
||||||
|
pubkey: string,
|
||||||
|
payload: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const key = `${type}:${this.npub}:${pubkey}:${payload}`;
|
||||||
|
if (type.endsWith('_decrypt')) {
|
||||||
|
const cached = decryptCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
}
|
||||||
|
const existing = cryptoInFlight.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const task = (async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const plugin = this.plugin();
|
||||||
|
const { result } = type === 'nip04_encrypt'
|
||||||
|
? await runAmber(() => plugin.nip04Encrypt({ payload, pubKey: pubkey, npub: this.npub }))
|
||||||
|
: type === 'nip04_decrypt'
|
||||||
|
? await runAmber(() => plugin.nip04Decrypt({ payload, pubKey: pubkey, npub: this.npub }))
|
||||||
|
: type === 'nip44_encrypt'
|
||||||
|
? await runAmber(() => plugin.nip44Encrypt({ payload, pubKey: pubkey, npub: this.npub }))
|
||||||
|
: await runAmber(() => plugin.nip44Decrypt({ payload, pubKey: pubkey, npub: this.npub }));
|
||||||
|
const decoded = await decodeAmberResult(result);
|
||||||
|
if (type.endsWith('_decrypt')) rememberDecrypt(key, decoded);
|
||||||
|
return decoded;
|
||||||
|
} catch (e) {
|
||||||
|
throw normalizeAmberError(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
cryptoInFlight.set(key, task);
|
||||||
|
try {
|
||||||
|
return await task;
|
||||||
|
} finally {
|
||||||
|
cryptoInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
readonly nip04 = {
|
readonly nip04 = {
|
||||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> =>
|
||||||
try {
|
this.crypto('nip04_encrypt', pubkey, plaintext),
|
||||||
const { result } = await this.plugin().nip04Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> =>
|
||||||
return decodeAmberResult(result);
|
this.crypto('nip04_decrypt', pubkey, ciphertext),
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const { result } = await this.plugin().nip04Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
|
|
||||||
return decodeAmberResult(result);
|
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly nip44 = {
|
readonly nip44 = {
|
||||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> =>
|
||||||
try {
|
this.crypto('nip44_encrypt', pubkey, plaintext),
|
||||||
const { result } = await this.plugin().nip44Encrypt({ payload: plaintext, pubKey: pubkey, npub: this.npub });
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> =>
|
||||||
return decodeAmberResult(result);
|
this.crypto('nip44_decrypt', pubkey, ciphertext),
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const { result } = await this.plugin().nip44Decrypt({ payload: ciphertext, pubKey: pubkey, npub: this.npub });
|
|
||||||
return decodeAmberResult(result);
|
|
||||||
} catch (e) { throw normalizeAmberError(e); }
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ export async function fetchGroupMessages(params: {
|
||||||
|
|
||||||
const out: CordnMessage[] = [];
|
const out: CordnMessage[] = [];
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
|
// Never keep opening Amber after the chat query has been cancelled.
|
||||||
|
if (signal.aborted) break;
|
||||||
const sealed = parseSealed(ev.content);
|
const sealed = parseSealed(ev.content);
|
||||||
if (!sealed) continue;
|
if (!sealed) continue;
|
||||||
const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId);
|
const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId);
|
||||||
|
|
|
||||||
55
src/lib/eventMerge.test.ts
Normal file
55
src/lib/eventMerge.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { eventMergeKey, eventReplaces, mergeEvents } from './eventMerge';
|
||||||
|
|
||||||
|
function ev(partial: Partial<NostrEvent> & { id: string }): NostrEvent {
|
||||||
|
return {
|
||||||
|
pubkey: 'a'.repeat(64),
|
||||||
|
created_at: 100,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: '',
|
||||||
|
sig: '0'.repeat(128),
|
||||||
|
...partial,
|
||||||
|
} as NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('eventMerge', () => {
|
||||||
|
it('keys addressable, replaceable and regular events correctly', () => {
|
||||||
|
expect(eventMergeKey(ev({ id: '1', kind: 30818, tags: [['d', 'spot']] }))).toBe(
|
||||||
|
`${'a'.repeat(64)}:30818:spot`,
|
||||||
|
);
|
||||||
|
expect(eventMergeKey(ev({ id: '2', kind: 0 }))).toBe(`${'a'.repeat(64)}:0`);
|
||||||
|
expect(eventMergeKey(ev({ id: '3', kind: 1 }))).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the newest addressable version regardless of input order', () => {
|
||||||
|
const old = ev({ id: 'b', kind: 30818, tags: [['d', 'spot']], created_at: 100 });
|
||||||
|
const fresh = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']], created_at: 101 });
|
||||||
|
|
||||||
|
expect(mergeEvents([old, fresh])).toEqual([fresh]);
|
||||||
|
expect(mergeEvents([fresh, old])).toEqual([fresh]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses event id as a deterministic tie-breaker', () => {
|
||||||
|
const first = ev({ id: 'aaa', kind: 30000, tags: [['d', 'x']], created_at: 100 });
|
||||||
|
const second = ev({ id: 'zzz', kind: 30000, tags: [['d', 'x']], created_at: 100 });
|
||||||
|
|
||||||
|
expect(eventReplaces(first, second)).toBe(true);
|
||||||
|
expect(mergeEvents([first, second])).toEqual([second]);
|
||||||
|
expect(mergeEvents([second, first])).toEqual([second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps distinct regular events and deduplicates by id', () => {
|
||||||
|
const a = ev({ id: 'a' });
|
||||||
|
const b = ev({ id: 'b' });
|
||||||
|
expect(mergeEvents([a, b, a])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops local tombstones', () => {
|
||||||
|
const a = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']] });
|
||||||
|
const b = ev({ id: 'b', kind: 30818, tags: [['d', 'other']] });
|
||||||
|
|
||||||
|
expect(mergeEvents([a, b], { isDeleted: (event) => event.id === 'a' })).toEqual([b]);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/lib/eventMerge.ts
Normal file
74
src/lib/eventMerge.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic event merging.
|
||||||
|
*
|
||||||
|
* Nostr does not provide global ordering: relays can return the same logical
|
||||||
|
* event set in a different order, and two relays can serve different versions
|
||||||
|
* of an addressable event. We do not attempt consensus; we make the client's
|
||||||
|
* local answer deterministic and explainable.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - addressable events (`d` tag): newest `created_at` wins;
|
||||||
|
* - replaceable events (kind 0/3 and 10000-19999): newest `created_at` wins;
|
||||||
|
* - regular events: identity is the event id;
|
||||||
|
* - equal timestamps: lexicographically larger event id wins, so relay order
|
||||||
|
* and Promise timing cannot change the result.
|
||||||
|
*
|
||||||
|
* Local NIP-09 tombstones are applied by the caller before/while merging; a
|
||||||
|
* tombstone is a device-local safety net, not a claim that every relay erased
|
||||||
|
* the event.
|
||||||
|
*/
|
||||||
|
export type EventMergePredicate = (event: NostrEvent) => boolean;
|
||||||
|
|
||||||
|
export interface EventMergeOptions {
|
||||||
|
/** Return true for events that must not appear (local tombstones, moderation). */
|
||||||
|
isDeleted?: EventMergePredicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable identity used to compare two versions of the same logical event. */
|
||||||
|
export function eventMergeKey(event: NostrEvent): string {
|
||||||
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
|
if (dTag !== undefined) return `${event.pubkey}:${event.kind}:${dTag}`;
|
||||||
|
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
||||||
|
return `${event.pubkey}:${event.kind}`;
|
||||||
|
}
|
||||||
|
return event.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when `candidate` should replace `current` for the same key.
|
||||||
|
* Larger ids win ties; this is arbitrary but stable across relays and devices.
|
||||||
|
*/
|
||||||
|
export function eventReplaces(current: NostrEvent, candidate: NostrEvent): boolean {
|
||||||
|
if (candidate.created_at !== current.created_at) {
|
||||||
|
return candidate.created_at > current.created_at;
|
||||||
|
}
|
||||||
|
return candidate.id > current.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge events from one or more relays into one deterministic list.
|
||||||
|
*
|
||||||
|
* The returned list is sorted by key only for stable snapshots; callers that
|
||||||
|
* care about display order should sort for their own UI.
|
||||||
|
*/
|
||||||
|
export function mergeEvents(
|
||||||
|
events: readonly NostrEvent[],
|
||||||
|
options: EventMergeOptions = {},
|
||||||
|
): NostrEvent[] {
|
||||||
|
const { isDeleted } = options;
|
||||||
|
const byKey = new Map<string, NostrEvent>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (isDeleted?.(event)) continue;
|
||||||
|
|
||||||
|
const key = eventMergeKey(event);
|
||||||
|
const current = byKey.get(key);
|
||||||
|
if (!current || eventReplaces(current, event)) {
|
||||||
|
byKey.set(key, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byKey.values()];
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ export interface LanguageOption {
|
||||||
name: string;
|
name: string;
|
||||||
nativeName: string;
|
nativeName: string;
|
||||||
flag: string;
|
flag: string;
|
||||||
|
/** Kept for stored preferences, but omitted from the visible picker. */
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,7 +28,9 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||||
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' },
|
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' },
|
||||||
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
|
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
|
||||||
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' },
|
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' },
|
||||||
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵' },
|
// North Korean reuses the Korean dictionary; hide the duplicate from the
|
||||||
|
// picker while keeping the code valid for existing stored preferences.
|
||||||
|
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵', hidden: true },
|
||||||
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
|
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
|
||||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
|
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
|
||||||
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' },
|
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' },
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export function isValidLightningAddress(address: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = address.trim();
|
const trimmed = address.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
|
||||||
const parts = trimmed.split('@');
|
const parts = trimmed.split('@');
|
||||||
if (parts.length !== 2) {
|
if (parts.length !== 2) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -60,7 +60,8 @@ export async function resolveLightningAddress(
|
||||||
throw new Error('Invalid Lightning address format. Expected: username@domain.com');
|
throw new Error('Invalid Lightning address format. Expected: username@domain.com');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [username, domain] = lightningAddress.split('@');
|
const normalized = lightningAddress.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
|
||||||
|
const [username, domain] = normalized.split('@');
|
||||||
const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`;
|
const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`;
|
||||||
|
|
||||||
const response = await fetch(lnurlEndpoint, {
|
const response = await fetch(lnurlEndpoint, {
|
||||||
|
|
|
||||||
60
src/lib/relayDivergence.test.ts
Normal file
60
src/lib/relayDivergence.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { compareRelaySnapshots } from './relayDivergence';
|
||||||
|
|
||||||
|
function profile(id: string, pubkey: string, created_at: number): NostrEvent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
pubkey,
|
||||||
|
created_at,
|
||||||
|
kind: 0,
|
||||||
|
tags: [],
|
||||||
|
content: '{}',
|
||||||
|
sig: '0'.repeat(128),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compareRelaySnapshots', () => {
|
||||||
|
it('reports a newer profile on another relay', () => {
|
||||||
|
const pubkey = 'a'.repeat(64);
|
||||||
|
const result = compareRelaySnapshots(
|
||||||
|
{ relay: 'canonical', events: [profile('old', pubkey, 10)] },
|
||||||
|
[{ relay: 'personal', events: [profile('new', pubkey, 20)] }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.differences).toHaveLength(1);
|
||||||
|
expect(result.differences[0].relay).toBe('personal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a profile missing from canonical', () => {
|
||||||
|
const pubkey = 'b'.repeat(64);
|
||||||
|
const result = compareRelaySnapshots(
|
||||||
|
{ relay: 'canonical', events: [] },
|
||||||
|
[{ relay: 'personal', events: [profile('only', pubkey, 10)] }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.differences).toHaveLength(1);
|
||||||
|
expect(result.differences[0].canonical).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not report equal snapshots', () => {
|
||||||
|
const pubkey = 'c'.repeat(64);
|
||||||
|
const event = profile('same', pubkey, 10);
|
||||||
|
const result = compareRelaySnapshots(
|
||||||
|
{ relay: 'canonical', events: [event] },
|
||||||
|
[{ relay: 'personal', events: [event] }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.differences).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records relay errors instead of treating them as empty', () => {
|
||||||
|
const result = compareRelaySnapshots(
|
||||||
|
{ relay: 'canonical', events: [] },
|
||||||
|
[{ relay: 'personal', events: [], error: 'timeout' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.errors).toEqual(['personal: timeout']);
|
||||||
|
expect(result.checkedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/lib/relayDivergence.ts
Normal file
71
src/lib/relayDivergence.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { eventMergeKey, eventReplaces } from './eventMerge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare profile snapshots from several relays.
|
||||||
|
*
|
||||||
|
* This is deliberately a diagnostic, not a consensus mechanism. A difference
|
||||||
|
* means "another relay served a newer or different signed profile", not that
|
||||||
|
* either relay is lying. We never merge canonical map/community data from this
|
||||||
|
* result.
|
||||||
|
*/
|
||||||
|
export interface RelaySnapshot {
|
||||||
|
relay: string;
|
||||||
|
events: NostrEvent[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayDifference {
|
||||||
|
key: string;
|
||||||
|
canonical: NostrEvent | null;
|
||||||
|
other: NostrEvent;
|
||||||
|
relay: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayDivergenceResult {
|
||||||
|
relayCount: number;
|
||||||
|
checkedCount: number;
|
||||||
|
differences: RelayDifference[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestByKey(events: NostrEvent[]): Map<string, NostrEvent> {
|
||||||
|
const byKey = new Map<string, NostrEvent>();
|
||||||
|
for (const event of events) {
|
||||||
|
const key = eventMergeKey(event);
|
||||||
|
const current = byKey.get(key);
|
||||||
|
if (!current || eventReplaces(current, event)) byKey.set(key, event);
|
||||||
|
}
|
||||||
|
return byKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareRelaySnapshots(
|
||||||
|
canonical: RelaySnapshot,
|
||||||
|
others: RelaySnapshot[],
|
||||||
|
): RelayDivergenceResult {
|
||||||
|
const canonicalByKey = latestByKey(canonical.events);
|
||||||
|
const differences: RelayDifference[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
let checkedCount = 0;
|
||||||
|
|
||||||
|
for (const snapshot of others) {
|
||||||
|
if (snapshot.error) {
|
||||||
|
errors.push(`${snapshot.relay}: ${snapshot.error}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
checkedCount += 1;
|
||||||
|
for (const [key, event] of latestByKey(snapshot.events)) {
|
||||||
|
const current = canonicalByKey.get(key) ?? null;
|
||||||
|
if (!current || eventReplaces(current, event)) {
|
||||||
|
differences.push({ key, canonical: current, other: event, relay: snapshot.relay });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
relayCount: 1 + others.length,
|
||||||
|
checkedCount,
|
||||||
|
differences,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -587,7 +587,8 @@ export function isValidSilentPayment(value: string | undefined | null): value is
|
||||||
*/
|
*/
|
||||||
export function isValidLightningAddress(value: string | undefined | null): value is string {
|
export function isValidLightningAddress(value: string | undefined | null): value is string {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value.trim());
|
const normalized = value.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
|
||||||
|
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Skater profile (kind 35000) ───────────────────────────────────────────
|
// ─── Skater profile (kind 35000) ───────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { useListingRestrictions, recordListingCreation } from '@/hooks/useListin
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { Nip05Required } from '@/components/Nip05Required';
|
import { Nip05Required } from '@/components/Nip05Required';
|
||||||
import { SpamFeePayment } from '@/components/SpamFeePayment';
|
import { SpamFeePayment } from '@/components/SpamFeePayment';
|
||||||
|
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||||||
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
|
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
|
||||||
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
|
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
|
||||||
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
|
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
|
||||||
|
|
@ -857,6 +858,8 @@ export default function CreateListingPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{listingType === 'selling' && <PaymentReadinessNote />}
|
||||||
|
|
||||||
{priceType === 'fixed' && (
|
{priceType === 'fixed' && (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="col-span-2 space-y-2">
|
<div className="col-span-2 space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useToast } from '@/hooks/useToast';
|
||||||
import { ShopBountyCard } from '@/components/ShopBountyCard';
|
import { ShopBountyCard } from '@/components/ShopBountyCard';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
|
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
|
||||||
|
import { normalizeClinkCode } from '@/lib/clink';
|
||||||
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import {
|
import {
|
||||||
|
|
@ -221,7 +222,7 @@ export default function CreateShopPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidNoffer(noffer.trim())) {
|
if (isValidNoffer(noffer.trim())) {
|
||||||
tags.push(['noffer', noffer.trim().toLowerCase()]);
|
tags.push(['noffer', normalizeClinkCode(noffer)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const handle of nip05s) tags.push(['nip05', handle]);
|
for (const handle of nip05s) tags.push(['nip05', handle]);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast';
|
||||||
import { useShop } from '@/hooks/useShops';
|
import { useShop } from '@/hooks/useShops';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
|
import { SKATE_SHOP_KIND, isValidNoffer } from '@/lib/types';
|
||||||
|
import { normalizeClinkCode } from '@/lib/clink';
|
||||||
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import {
|
import {
|
||||||
|
|
@ -266,7 +267,7 @@ export default function EditShopPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidNoffer(noffer.trim())) {
|
if (isValidNoffer(noffer.trim())) {
|
||||||
tags.push(['noffer', noffer.trim().toLowerCase()]);
|
tags.push(['noffer', normalizeClinkCode(noffer)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const handle of nip05s) tags.push(['nip05', handle]);
|
for (const handle of nip05s) tags.push(['nip05', handle]);
|
||||||
|
|
|
||||||
7
src/pages/HomePage.native.tsx
Normal file
7
src/pages/HomePage.native.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Capacitor build alias for the home route. The native WebView boots directly
|
||||||
|
* into the map; do not turn this into a lazy chunk here — the Android shell
|
||||||
|
* serves a single inlined bundle and an unresolved lazy import was the cause of
|
||||||
|
* a black boot screen.
|
||||||
|
*/
|
||||||
|
export { default } from './MapPage';
|
||||||
11
src/pages/HomePage.tsx
Normal file
11
src/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { lazyWithRetry } from '@/lib/lazyWithRetry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web/desktop home route: keep the map (and its card/Zap dependencies) out of
|
||||||
|
* the initial entry chunk. Vite aliases this module to HomePage.native.tsx in
|
||||||
|
* the Capacitor build, where the map stays eager so the native WebView can
|
||||||
|
* never show a blank Suspense screen on boot.
|
||||||
|
*/
|
||||||
|
const HomePage = lazyWithRetry(() => import('./MapPage'));
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
|
|
@ -14,6 +14,7 @@ import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
|
||||||
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||||
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||||
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
|
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
|
||||||
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { OfferDialog } from '@/components/OfferDialog';
|
import { OfferDialog } from '@/components/OfferDialog';
|
||||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
|
|
@ -22,9 +23,10 @@ import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||||
|
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { ErrorState } from '@/components/ErrorState';
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES } from '@/lib/types';
|
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES, isValidNoffer } from '@/lib/types';
|
||||||
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
|
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -39,6 +41,7 @@ import {
|
||||||
Tag,
|
Tag,
|
||||||
Handshake,
|
Handshake,
|
||||||
Share2,
|
Share2,
|
||||||
|
ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
@ -60,6 +63,7 @@ export default function ListingDetailPage() {
|
||||||
const author = useAuthor(pubkey);
|
const author = useAuthor(pubkey);
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const { data: sellerInfo } = useSellerInfo(pubkey);
|
const { data: sellerInfo } = useSellerInfo(pubkey);
|
||||||
|
const { data: sellerReputation } = useTradeReputation(pubkey);
|
||||||
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||||
|
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
@ -123,6 +127,9 @@ export default function ListingDetailPage() {
|
||||||
: null;
|
: null;
|
||||||
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown'));
|
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown'));
|
||||||
const npub = pubkey ? nip19.npubEncode(pubkey) : '';
|
const npub = pubkey ? nip19.npubEncode(pubkey) : '';
|
||||||
|
const sellerNoffer = typeof (metadata as Record<string, unknown> | undefined)?.noffer === 'string'
|
||||||
|
? ((metadata as Record<string, unknown>).noffer as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
|
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
|
||||||
|
|
||||||
|
|
@ -336,7 +343,25 @@ export default function ListingDetailPage() {
|
||||||
) : (
|
) : (
|
||||||
<Card className="bg-black/90 border-white/10">
|
<Card className="bg-black/90 border-white/10">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
|
{(sellerInfo?.accountAge !== undefined && sellerInfo.accountAge < 30) ||
|
||||||
|
(sellerReputation && sellerReputation.verifiedCounterparties === 0) ? (
|
||||||
|
<div className="mb-3 space-y-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-amber-300">
|
||||||
|
<ShieldAlert className="h-3.5 w-3.5" /> {t('marketUi.card.newSeller')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-relaxed text-gray-400">
|
||||||
|
{t('marketTerms.safetyBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<ListingTradeActions item={item} />
|
<ListingTradeActions item={item} />
|
||||||
|
{isValidNoffer(sellerNoffer) && (
|
||||||
|
<ClinkPayButton
|
||||||
|
noffer={sellerNoffer}
|
||||||
|
recipientName={displayName}
|
||||||
|
className="mt-2 w-full justify-center"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{user && item.priceType !== 'fixed' && (
|
{user && item.priceType !== 'fixed' && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import { EventCard } from '@/components/EventCard';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
|
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND, isValidNoffer, isValidLightningAddress } from '@/lib/types';
|
||||||
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
|
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, ShieldAlert, Loader2, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus, UserPlus, UserCheck, Handshake } from 'lucide-react';
|
import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, ShieldAlert, Loader2, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus, UserPlus, UserCheck, Handshake } from 'lucide-react';
|
||||||
|
|
@ -150,6 +150,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const raw = metadata as Record<string, unknown> | undefined;
|
const raw = metadata as Record<string, unknown> | undefined;
|
||||||
const noffer = raw?.noffer;
|
const noffer = raw?.noffer;
|
||||||
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
||||||
|
const hasClink = isValidNoffer(nofferCode);
|
||||||
|
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06;
|
||||||
// Extra custom profile fields.
|
// Extra custom profile fields.
|
||||||
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
|
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
|
||||||
const userBadges = parseBadges(metadata);
|
const userBadges = parseBadges(metadata);
|
||||||
|
|
@ -160,6 +162,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
// one-tap "Add" instead of just a link.
|
// one-tap "Add" instead of just a link.
|
||||||
const cordnPointer = cordnUrl ? parseCoordinatorInput(cordnUrl) : null;
|
const cordnPointer = cordnUrl ? parseCoordinatorInput(cordnUrl) : null;
|
||||||
const [spCopied, setSpCopied] = useState(false);
|
const [spCopied, setSpCopied] = useState(false);
|
||||||
|
const [lud16Copied, setLud16Copied] = useState(false);
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
|
||||||
const npub = nip19.npubEncode(pubkey);
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
|
@ -892,14 +895,30 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{lud16 && (
|
{hasLightning && lud16 && (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
|
<button
|
||||||
<Zap className="h-4 w-4 text-yellow-400 flex-shrink-0" />
|
type="button"
|
||||||
<div className="min-w-0">
|
onClick={() => {
|
||||||
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">{t('walletUi.profile.lightning')}</p>
|
void navigator.clipboard.writeText(lud16).then(() => {
|
||||||
<span className="text-gray-300 text-sm truncate block">{lud16}</span>
|
setLud16Copied(true);
|
||||||
</div>
|
setTimeout(() => setLud16Copied(false), 2000);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg bg-gray-900 p-3 text-left transition-colors hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4 flex-shrink-0 text-yellow-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wide text-gray-500">
|
||||||
|
{t('walletUi.profile.lightning')} {lud16Copied && <span className="text-green-400">· {t('common.copied')}</span>}
|
||||||
|
</p>
|
||||||
|
<span className="block truncate text-sm text-gray-300">{lud16}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{lud16Copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5 flex-shrink-0 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5 flex-shrink-0 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bitcoin Silent Payment address — copyable */}
|
{/* Bitcoin Silent Payment address — copyable */}
|
||||||
|
|
@ -1052,7 +1071,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tip Card */}
|
{/* Tip Card */}
|
||||||
{!isOwnProfile && (profileEvent || nofferCode) && (
|
{!isOwnProfile && (hasLightning || hasClink) && (
|
||||||
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
|
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
|
||||||
<CardContent className="p-4 text-center">
|
<CardContent className="p-4 text-center">
|
||||||
<Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
|
<Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
|
||||||
|
|
@ -1061,13 +1080,13 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
{t('walletUi.profile.supportSkaterDesc')}
|
{t('walletUi.profile.supportSkaterDesc')}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{profileEvent && (
|
{profileEvent && hasLightning && (
|
||||||
<ZapButton
|
<ZapButton
|
||||||
target={profileEvent}
|
target={profileEvent}
|
||||||
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{nofferCode && (
|
{hasClink && nofferCode && (
|
||||||
<ClinkPayButton
|
<ClinkPayButton
|
||||||
noffer={nofferCode}
|
noffer={nofferCode}
|
||||||
recipientName={displayName}
|
recipientName={displayName}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useSeoMeta } from "@unhead/react";
|
import { useSeoMeta } from "@unhead/react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
@ -24,9 +24,9 @@ const NotFound = () => {
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">404</h1>
|
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">404</h1>
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-400 mb-4">{t('settingsUi.notFound.message')}</p>
|
<p className="text-xl text-gray-600 dark:text-gray-400 mb-4">{t('settingsUi.notFound.message')}</p>
|
||||||
<a href="/" className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline">
|
<Link to="/" className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline">
|
||||||
{t('settingsUi.notFound.returnHome')}
|
{t('settingsUi.notFound.returnHome')}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
171
src/pages/OpsPage.tsx
Normal file
171
src/pages/OpsPage.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { Activity, AlertTriangle, CheckCircle2, Loader2, RefreshCw, Server, ShieldAlert } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
const OPS_URL = 'https://relay.skate-spots.org/ops';
|
||||||
|
|
||||||
|
interface OpsStatus {
|
||||||
|
updatedAt: string;
|
||||||
|
relay: { local: string; public: string };
|
||||||
|
version: string;
|
||||||
|
cordn: { announces20m: number; rejects15m: number };
|
||||||
|
storage: { diskPct: number; memAvailMiB: number; mediaBytes: number };
|
||||||
|
tls: { days: number };
|
||||||
|
services: Record<string, boolean>;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, good = true }: { label: string; value: string; good?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/40 p-3">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{label}</p>
|
||||||
|
<p className={`mt-1 text-lg font-black ${good ? 'text-white' : 'text-red-400'}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only, read-only relay operator snapshot. The endpoint requires a
|
||||||
|
* NIP-98 signature from one of the two relay admin pubkeys; this page never
|
||||||
|
* exposes a restart/config action.
|
||||||
|
*
|
||||||
|
* Labels are intentionally English-only: this is an internal operator tool,
|
||||||
|
* not a public product surface.
|
||||||
|
*/
|
||||||
|
export default function OpsPage() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [status, setStatus] = useState<OpsStatus | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'Operator status — SkateSpots',
|
||||||
|
description: 'Private relay health snapshot.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const event = await user.signer.signEvent({
|
||||||
|
kind: 27235,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['u', OPS_URL],
|
||||||
|
['method', 'GET'],
|
||||||
|
['expiration', String(Math.floor(Date.now() / 1000) + 120)],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const response = await fetch(OPS_URL, {
|
||||||
|
headers: { authorization: `Nostr ${btoa(JSON.stringify(event))}` },
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
||||||
|
setStatus(body as OpsStatus);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (!isAdminPubkey(user?.pubkey)) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-lg px-4 py-16 text-center">
|
||||||
|
<ShieldAlert className="mx-auto mb-3 h-10 w-10 text-red-400/60" />
|
||||||
|
<h1 className="text-xl font-black uppercase text-white">Admins only</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">This page is restricted to the two relay admin npubs.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl space-y-4 px-4 py-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-[#ccff00]">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest">Private operator surface</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1 text-3xl font-black uppercase leading-none text-white">Relay status</h1>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Read-only snapshot, signed by your admin npub.</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void load()} disabled={loading} className="gap-2">
|
||||||
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||||
|
Load status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-300">
|
||||||
|
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!status ? (
|
||||||
|
<Card className="border-white/10 bg-black/50">
|
||||||
|
<CardContent className="space-y-2 py-10 text-center">
|
||||||
|
<Server className="mx-auto h-10 w-10 text-gray-600" />
|
||||||
|
<p className="text-sm text-gray-400">Load the signed snapshot to inspect the relay.</p>
|
||||||
|
<p className="text-[11px] text-gray-600">With Amber this requires one approval.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
{status.issues.length === 0 ? (
|
||||||
|
<><CheckCircle2 className="h-3.5 w-3.5 text-green-400" /> All checks passing</>
|
||||||
|
) : (
|
||||||
|
<><AlertTriangle className="h-3.5 w-3.5 text-amber-400" /> {status.issues.length} issue(s)</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-600">Updated {new Date(status.updatedAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<Metric label="Relay" value={`${status.relay.public} / ${status.relay.local}`} good={status.relay.public === '200' && status.relay.local === '200'} />
|
||||||
|
<Metric label="Version" value={status.version || '—'} />
|
||||||
|
<Metric label="Disk" value={`${status.storage.diskPct}%`} good={status.storage.diskPct < 85} />
|
||||||
|
<Metric label="Memory free" value={`${status.storage.memAvailMiB} MiB`} good={status.storage.memAvailMiB > 300} />
|
||||||
|
<Metric label="TLS" value={`${status.tls.days}d`} good={status.tls.days > 14} />
|
||||||
|
<Metric label="Media" value={`${(status.storage.mediaBytes / 1024 / 1024).toFixed(1)} MiB`} />
|
||||||
|
<Metric label="Cordn announces" value={String(status.cordn.announces20m)} good={status.cordn.announces20m > 0} />
|
||||||
|
<Metric label="Cordn 429s / 15m" value={String(status.cordn.rejects15m)} good={status.cordn.rejects15m < 40} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-white/10 bg-black/50">
|
||||||
|
<CardHeader className="pb-2"><CardTitle className="text-sm text-white">Services</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-3">
|
||||||
|
{Object.entries(status.services).map(([name, up]) => (
|
||||||
|
<div key={name} className="flex items-center gap-2 text-gray-400">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${up ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{status.issues.length > 0 && (
|
||||||
|
<Card className="border-amber-500/25 bg-amber-500/[0.04]">
|
||||||
|
<CardHeader className="pb-2"><CardTitle className="text-sm text-amber-300">Issues</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-xs text-amber-200/80">
|
||||||
|
{status.issues.map((issue) => <p key={issue}>• {issue}</p>)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] text-gray-700">{t('settingsUi.settingsPage.advanced')} · read-only</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
||||||
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
||||||
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
||||||
import { RelayListManager } from '@/components/RelayListManager';
|
import { RelayListManager } from '@/components/RelayListManager';
|
||||||
|
import { RelayDivergenceCard } from '@/components/RelayDivergenceCard';
|
||||||
|
|
||||||
import { WalletModal } from '@/components/WalletModal';
|
import { WalletModal } from '@/components/WalletModal';
|
||||||
import { SecuritySettings } from '@/components/SecuritySettings';
|
import { SecuritySettings } from '@/components/SecuritySettings';
|
||||||
|
|
@ -607,6 +608,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
|
||||||
)}
|
)}
|
||||||
<CitrineCard />
|
<CitrineCard />
|
||||||
<RelayListManager />
|
<RelayListManager />
|
||||||
|
<RelayDivergenceCard />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
|
||||||
// Only count support received from this point (the project's public launch era).
|
// Only count support received from this point (the project's public launch era).
|
||||||
|
|
@ -27,7 +28,7 @@ import { Button } from '@/components/ui/button';
|
||||||
// platform zap is already ≥ 2026-01, so this doesn't currently trim the list —
|
// platform zap is already ≥ 2026-01, so this doesn't currently trim the list —
|
||||||
// the visible cap below is what keeps it short.
|
// the visible cap below is what keeps it short.
|
||||||
const SUPPORTERS_SINCE = Math.floor(Date.UTC(2025, 11, 1) / 1000); // 2025-12-01
|
const SUPPORTERS_SINCE = Math.floor(Date.UTC(2025, 11, 1) / 1000); // 2025-12-01
|
||||||
const VISIBLE_LIMIT = 21;
|
const VISIBLE_LIMIT = 12;
|
||||||
|
|
||||||
/** npub → hex pubkey (useAuthor queries relays by HEX authors). */
|
/** npub → hex pubkey (useAuthor queries relays by HEX authors). */
|
||||||
function npubToHex(npub?: string): string | undefined {
|
function npubToHex(npub?: string): string | undefined {
|
||||||
|
|
@ -261,15 +262,15 @@ function SectionHeader({
|
||||||
color?: string;
|
color?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 md:mb-8">
|
<div className="mb-3 md:mb-4">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="mb-1.5 flex items-center gap-2.5">
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" style={{ color }} />
|
<Icon className="h-4 w-4 flex-shrink-0" style={{ color }} />
|
||||||
<h2 className="text-2xl md:text-3xl font-black uppercase tracking-wide" style={{ color }}>
|
<h2 className="text-lg font-black uppercase tracking-wide md:text-xl" style={{ color }}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex-1 h-px" style={{ background: `linear-gradient(to right, ${color}40, transparent)` }} />
|
<div className="h-px flex-1" style={{ background: `linear-gradient(to right, ${color}40, transparent)` }} />
|
||||||
</div>
|
</div>
|
||||||
{subtitle && <p className="text-gray-400 text-sm ml-8">{subtitle}</p>}
|
{subtitle && <p className="ml-7 text-xs text-gray-400">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -547,19 +548,6 @@ function SupporterRow({ rank, entry }: { rank: number; entry: SupporterEntry })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Placeholder card (empty slot) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
function PlaceholderCard({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3 p-5 rounded-xl border border-dashed border-white/10 text-center opacity-40">
|
|
||||||
<div className="w-16 h-16 rounded-2xl border border-dashed border-white/20 flex items-center justify-center">
|
|
||||||
<span className="text-2xl">?</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600 font-medium">{label}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SupportersPage() {
|
export default function SupportersPage() {
|
||||||
|
|
@ -571,6 +559,8 @@ export default function SupportersPage() {
|
||||||
const totalUsd = useSatsToUsd(allSats);
|
const totalUsd = useSatsToUsd(allSats);
|
||||||
const { data: stats } = useGrowthStats();
|
const { data: stats } = useGrowthStats();
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const [tab, setTab] = useState<'support' | 'backers' | 'community' | 'grants'>('support');
|
||||||
|
const [rail, setRail] = useState<'support-lightning' | 'support-silent' | 'support-angor'>('support-lightning');
|
||||||
|
|
||||||
// One ranked wall: Angor backers + Lightning zaps to the project, merged by npub.
|
// One ranked wall: Angor backers + Lightning zaps to the project, merged by npub.
|
||||||
const supporters = useMemo<SupporterEntry[]>(() => {
|
const supporters = useMemo<SupporterEntry[]>(() => {
|
||||||
|
|
@ -652,97 +642,106 @@ export default function SupportersPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container max-w-4xl px-4 py-12 md:py-16 space-y-16">
|
<div className="container max-w-4xl space-y-6 px-4 py-8 md:py-10">
|
||||||
|
|
||||||
{/* ── Hero ── */}
|
{/* ── Hero ── */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-6xl mb-4">⚡</div>
|
<h1 className="text-4xl font-black uppercase leading-none md:text-5xl">
|
||||||
<h1 className="text-5xl md:text-6xl font-black uppercase mb-3 leading-none">
|
|
||||||
<span className="neon-red">{t('walletUi.supporters.our')}</span>{' '}
|
<span className="neon-red">{t('walletUi.supporters.our')}</span>{' '}
|
||||||
<span className="neon-lime">{t('walletUi.supporters.community')}</span>
|
<span className="neon-lime">{t('walletUi.supporters.community')}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 max-w-lg mx-auto text-base mb-8">
|
<p className="mx-auto mt-2 max-w-xl text-sm text-gray-400">
|
||||||
{t('walletUi.supporters.heroDesc')}
|
{t('walletUi.supporters.heroDesc')}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={ANGOR_PROJECT_URL}
|
href={ANGOR_PROJECT_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] text-white font-black uppercase text-sm hover:opacity-90 transition-all shadow-lg shadow-[#ff0040]/30"
|
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] px-5 py-2.5 text-xs font-black uppercase text-white shadow-lg shadow-[#ff0040]/25 transition-opacity hover:opacity-90"
|
||||||
>
|
>
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
{t('walletUi.supporters.backAngor')}
|
{t('walletUi.supporters.backAngor')}
|
||||||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
<ExternalLink className="h-3 w-3 opacity-70" />
|
||||||
</a>
|
</a>
|
||||||
<Link
|
<Link
|
||||||
to="/donate"
|
to="/donate"
|
||||||
className="mt-5 inline-flex items-center gap-1.5 text-xs font-bold text-[#ccff00]/80 transition-colors hover:text-[#ccff00]"
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/15 px-4 py-2.5 text-xs font-bold text-[#ccff00]/80 transition-colors hover:border-[#ccff00]/40 hover:text-[#ccff00]"
|
||||||
>
|
>
|
||||||
{t('walletUi.supporters.donatePageLink')} →
|
{t('walletUi.supporters.donatePageLink')} →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-[11px] text-gray-500">
|
||||||
{/* ── 1. Support ── */}
|
<span>{supporters.length.toLocaleString()} {t('walletUi.supporters.backers')}</span>
|
||||||
<section>
|
<span aria-hidden="true">·</span>
|
||||||
<SectionHeader
|
<span>{allSats.toLocaleString()} sats</span>
|
||||||
icon={Zap}
|
{typeof totalUsd === 'number' && (
|
||||||
title={t('walletUi.supporters.support')}
|
<>
|
||||||
subtitle={t('walletUi.supporters.supportDesc')}
|
<span aria-hidden="true">·</span>
|
||||||
color="#f7931a"
|
<span>≈ ${totalUsd.toFixed(2)}</span>
|
||||||
/>
|
</>
|
||||||
|
)}
|
||||||
<WaysToSupport className="mb-4" />
|
</div>
|
||||||
|
|
||||||
{/* Lightning rail — the in-app way to chip in (zap publicly or pay quietly). */}
|
|
||||||
<div id="support-lightning" className="scroll-mt-24">
|
|
||||||
<SupportSkateSpots className="mt-4" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Silent payments — private on-chain (and Lightning) via a BIP-353 name. */}
|
{/* ── Compact navigation ── */}
|
||||||
<div id="support-silent" className="scroll-mt-24">
|
<Tabs
|
||||||
<SilentPaymentsCard className="mt-4" />
|
value={tab}
|
||||||
</div>
|
onValueChange={(value) => setTab(value as 'support' | 'backers' | 'community' | 'grants')}
|
||||||
<div id="support-angor" className="scroll-mt-24">
|
>
|
||||||
<AngorProjectCard count={supporters.length} className="mt-4" />
|
<TabsList className="grid h-auto w-full grid-cols-4 gap-1 rounded-2xl border border-white/10 bg-black/70 p-1">
|
||||||
</div>
|
{[
|
||||||
</section>
|
{ value: 'support', label: t('walletUi.supporters.support') },
|
||||||
|
{ value: 'backers', label: t('walletUi.supporters.backers') },
|
||||||
|
{ value: 'community', label: t('walletUi.supporters.community') },
|
||||||
|
{ value: 'grants', label: t('walletUi.supporters.grantsSection') },
|
||||||
|
].map((item) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
className="h-auto min-h-10 whitespace-normal rounded-xl px-1.5 py-2 text-center text-[9px] font-black uppercase leading-tight tracking-wide data-[state=active]:bg-[#ccff00] data-[state=active]:text-black data-[state=active]:shadow-none sm:px-2 sm:text-[11px]"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{/* ── 2. Bitcoin grantmakers ── */}
|
{/* ── Support rails ── */}
|
||||||
<section>
|
<TabsContent value="support" className="space-y-4 pt-4">
|
||||||
<SectionHeader
|
<div>
|
||||||
icon={Handshake}
|
<h2 className="text-xl font-black uppercase text-white">{t('walletUi.supporters.support')}</h2>
|
||||||
title={t('walletUi.supporters.grantsSection')}
|
<p className="mt-0.5 text-sm text-gray-400">{t('walletUi.supporters.donateDesc')}</p>
|
||||||
subtitle={t('walletUi.supporters.grantsSectionDesc')}
|
</div>
|
||||||
color="#f7931a"
|
<WaysToSupport active={rail} onSelect={(id) => setRail(id as typeof rail)} />
|
||||||
/>
|
<div id={rail} className="scroll-mt-24">
|
||||||
<GrantmakerForm />
|
{rail === 'support-lightning' && <SupportSkateSpots />}
|
||||||
</section>
|
{rail === 'support-silent' && <SilentPaymentsCard />}
|
||||||
|
{rail === 'support-angor' && <AngorProjectCard count={supporters.length} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 3. What your support pays for (words only) ── */}
|
<section className="pt-1">
|
||||||
<section>
|
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={Heart}
|
icon={Heart}
|
||||||
title={t('walletUi.supporters.paysFor')}
|
title={t('walletUi.supporters.paysFor')}
|
||||||
subtitle={t('walletUi.supporters.paysForDesc')}
|
subtitle={t('walletUi.supporters.paysForDesc')}
|
||||||
color="#22d3ee"
|
color="#22d3ee"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{PAYS_FOR.map(({ icon: Icon, key, desc }) => (
|
{PAYS_FOR.map(({ icon: Icon, key, desc }) => (
|
||||||
<div
|
<div key={key} className="flex items-start gap-2 rounded-xl border border-white/10 bg-black/60 px-3 py-2">
|
||||||
key={key}
|
<Icon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#22d3ee]" />
|
||||||
className="flex items-start gap-3 rounded-xl border border-white/10 bg-black/60 px-4 py-3"
|
|
||||||
>
|
|
||||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#22d3ee]" />
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-white">{t(key)}</span>
|
<span className="text-xs font-medium text-white">{t(key)}</span>
|
||||||
<p className="text-[11px] text-gray-500">{t(desc)}</p>
|
<p className="text-[10px] leading-snug text-gray-500">{t(desc)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── 3. Impact so far (what the community built) ── */}
|
{/* ── Backers, impact and tiers ── */}
|
||||||
|
<TabsContent value="backers" className="space-y-5 pt-4">
|
||||||
{impact.length > 0 && (
|
{impact.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
|
|
@ -751,16 +750,16 @@ export default function SupportersPage() {
|
||||||
subtitle={t('walletUi.supporters.impactDesc')}
|
subtitle={t('walletUi.supporters.impactDesc')}
|
||||||
color="#ccff00"
|
color="#ccff00"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||||
{impact.map(({ key, value }) => (
|
{impact.map(({ key, value }) => (
|
||||||
<div key={key} className="rounded-xl border border-white/10 bg-black/60 px-4 py-3 text-center">
|
<div key={key} className="rounded-xl border border-white/10 bg-black/60 px-2 py-2.5 text-center">
|
||||||
<div className="text-2xl font-black text-[#ccff00]">{value.toLocaleString()}</div>
|
<div className="text-lg font-black text-[#ccff00]">{value.toLocaleString()}</div>
|
||||||
<div className="text-xs text-gray-400">{t(key)}</div>
|
<div className="truncate text-[10px] text-gray-400">{t(key)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-center text-xs text-gray-500">
|
<p className="mt-2 text-center text-[11px] text-gray-500">
|
||||||
<Link to="/stats" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">
|
<Link to="/stats" className="text-[#ccff00]/70 underline underline-offset-2 hover:text-[#ccff00]">
|
||||||
{t('walletUi.supporters.impactSeeStats')}
|
{t('walletUi.supporters.impactSeeStats')}
|
||||||
</Link>
|
</Link>
|
||||||
{' · '}
|
{' · '}
|
||||||
|
|
@ -769,7 +768,6 @@ export default function SupportersPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 4. Supporters (Angor + Lightning zaps) ── */}
|
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
|
|
@ -777,19 +775,19 @@ export default function SupportersPage() {
|
||||||
subtitle={t('walletUi.supporters.backersDesc')}
|
subtitle={t('walletUi.supporters.backersDesc')}
|
||||||
color="#22d3ee"
|
color="#22d3ee"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{supporters.length > 0 ? (
|
{supporters.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
{(showAll ? supporters : supporters.slice(0, VISIBLE_LIMIT)).map((entry, i) => (
|
{(showAll ? supporters : supporters.slice(0, VISIBLE_LIMIT)).map((entry, i) => (
|
||||||
<SupporterRow key={entry.key} rank={i + 1} entry={entry} />
|
<SupporterRow key={entry.key} rank={i + 1} entry={entry} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{supporters.length > VISIBLE_LIMIT && (
|
{supporters.length > VISIBLE_LIMIT && (
|
||||||
<div className="mt-3 text-center">
|
<div className="mt-2 text-center">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowAll((v) => !v)}
|
onClick={() => setShowAll((v) => !v)}
|
||||||
className="min-h-0 min-w-0 text-xs font-bold uppercase tracking-wide text-[#22d3ee]/80 transition-colors hover:text-[#22d3ee]"
|
className="min-h-0 min-w-0 text-[11px] font-bold uppercase tracking-wide text-[#22d3ee]/80 transition-colors hover:text-[#22d3ee]"
|
||||||
>
|
>
|
||||||
{showAll
|
{showAll
|
||||||
? t('walletUi.supporters.showLess')
|
? t('walletUi.supporters.showLess')
|
||||||
|
|
@ -797,47 +795,62 @@ export default function SupportersPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<p className="mt-3 text-center text-xs text-gray-400">
|
||||||
<p className="mt-4 text-center text-sm text-gray-400">
|
|
||||||
{t('walletUi.supporters.supportersTotals', {
|
{t('walletUi.supporters.supportersTotals', {
|
||||||
count: supporters.length,
|
count: supporters.length,
|
||||||
angor: BACKERS_TOTAL_SATS.toLocaleString(),
|
angor: BACKERS_TOTAL_SATS.toLocaleString(),
|
||||||
zap: zapTotal.toLocaleString(),
|
zap: zapTotal.toLocaleString(),
|
||||||
})}
|
})}
|
||||||
{typeof totalUsd === 'number' && (
|
{typeof totalUsd === 'number' && <span className="text-gray-500"> · ≈ ${totalUsd.toFixed(2)}</span>}
|
||||||
<span className="text-gray-500"> · ≈ ${totalUsd.toFixed(2)}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-center text-xs text-gray-600">
|
|
||||||
{t('walletUi.supporters.quietNote')}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-center text-[11px] text-gray-600">{t('walletUi.supporters.quietNote')}</p>
|
||||||
{latestAt > 0 && (
|
{latestAt > 0 && (
|
||||||
<p className="mt-1 text-center text-[11px] text-gray-600">
|
<p className="mt-0.5 text-center text-[10px] text-gray-600">
|
||||||
{t('walletUi.supporters.updatedAt', {
|
{t('walletUi.supporters.updatedAt', { when: formatDistanceToNow(new Date(latestAt * 1000), { addSuffix: true }) })}
|
||||||
when: formatDistanceToNow(new Date(latestAt * 1000), { addSuffix: true }),
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<TierLadder supporters={supporters} />
|
<TierLadder supporters={supporters} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-[#22d3ee]/20 p-8 text-center">
|
<div className="rounded-xl border border-dashed border-[#22d3ee]/20 p-6 text-center">
|
||||||
<Zap className="h-10 w-10 mx-auto mb-3 text-[#22d3ee]/30" />
|
<Zap className="mx-auto mb-2 h-8 w-8 text-[#22d3ee]/30" />
|
||||||
<p className="text-gray-500 text-sm mb-1">{t('walletUi.supporters.beFirstBacker')}</p>
|
<p className="mb-1 text-sm text-gray-500">{t('walletUi.supporters.beFirstBacker')}</p>
|
||||||
<a
|
<a href={ANGOR_PROJECT_URL} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-xs font-bold text-[#22d3ee]/80 hover:text-[#22d3ee]">
|
||||||
href={ANGOR_PROJECT_URL}
|
<ExternalLink className="h-3 w-3" /> {t('walletUi.supporters.backAngor')}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-[#22d3ee]/80 hover:text-[#22d3ee] transition-colors"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
{t('walletUi.supporters.backAngor')}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Community, mission, collaborators, partners, friends ── */}
|
||||||
|
<TabsContent value="community" className="space-y-6 pt-4">
|
||||||
|
<section className="rounded-2xl border border-[#ff0040]/25 bg-black/70 p-5">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Heart className="h-5 w-5 text-[#ff0040]" />
|
||||||
|
<h2 className="text-center text-lg font-black uppercase text-white">
|
||||||
|
{t('walletUi.supporters.whyTitle1')}{' '}
|
||||||
|
<span className="neon-red">{t('walletUi.supporters.whyTitle2')}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mx-auto mt-2 max-w-2xl text-center text-xs leading-relaxed text-gray-400">
|
||||||
|
{t('walletUi.supporters.whyDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ emoji: '🛹', title: t('walletUi.supporters.why1Title'), desc: t('walletUi.supporters.why1Desc') },
|
||||||
|
{ emoji: '⚡', title: t('walletUi.supporters.why2Title'), desc: t('walletUi.supporters.why2Desc') },
|
||||||
|
{ emoji: '🌍', title: t('walletUi.supporters.why3Title'), desc: t('walletUi.supporters.why3Desc') },
|
||||||
|
].map(({ emoji, title, desc }) => (
|
||||||
|
<div key={title} className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-center">
|
||||||
|
<div className="text-2xl">{emoji}</div>
|
||||||
|
<p className="mt-1 text-xs font-bold">{title}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-gray-500">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── 6. Collaborators ── */}
|
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={Users}
|
icon={Users}
|
||||||
|
|
@ -845,124 +858,68 @@ export default function SupportersPage() {
|
||||||
subtitle={t('walletUi.supporters.collaboratorsDesc')}
|
subtitle={t('walletUi.supporters.collaboratorsDesc')}
|
||||||
color="#ff0040"
|
color="#ff0040"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{COLLABORATORS.length > 0 ? (
|
{COLLABORATORS.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
{COLLABORATORS.map((c) => (
|
{COLLABORATORS.map((c) => <CollaboratorCard key={c.name} collab={c} />)}
|
||||||
<CollaboratorCard key={c.name} collab={c} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-[#ff0040]/20 p-8 text-center">
|
<div className="rounded-xl border border-dashed border-[#ff0040]/20 p-5 text-center">
|
||||||
<Users className="h-10 w-10 mx-auto mb-3 text-[#ff0040]/30" />
|
<Users className="mx-auto mb-2 h-8 w-8 text-[#ff0040]/30" />
|
||||||
<p className="text-gray-500 text-sm mb-1">{t('walletUi.supporters.wantCollaborate')}</p>
|
<p className="mb-1 text-sm text-gray-500">{t('walletUi.supporters.wantCollaborate')}</p>
|
||||||
<p className="text-xs text-gray-600 mb-4">{t('walletUi.supporters.wantCollaborateDesc')}</p>
|
<p className="mb-3 text-xs text-gray-600">{t('walletUi.supporters.wantCollaborateDesc')}</p>
|
||||||
<a
|
<a href="https://nostrhub.io/naddr1qvzqqqrhnypzqg88e9f588nx7wyz4wt0ura23w3yd4hmztqrkr95utwyvgachjepqyd8wumn8ghj7emfwsh8x6rpddjhxur9v9ex2tnyd9uj7qq22d4kzar92dcx7arnhyhdwn" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-xs font-bold text-[#ff0040]/70 hover:text-[#ff0040]">
|
||||||
href="https://nostrhub.io/naddr1qvzqqqrhnypzqg88e9f588nx7wyz4wt0ura23w3yd4hmztqrkr95utwyvgachjepqyd8wumn8ghj7emfwsh8x6rpddjhxur9v9ex2tnyd9uj7qq22d4kzar92dcx7arnhyhdwn"
|
<ExternalLink className="h-3 w-3" /> {t('walletUi.supporters.viewNostrHub')}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-[#ff0040]/70 hover:text-[#ff0040] transition-colors"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
{t('walletUi.supporters.viewNostrHub')}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── 7. Partners ── */}
|
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader
|
<SectionHeader icon={Handshake} title={t('walletUi.supporters.partners')} subtitle={t('walletUi.supporters.partnersDesc')} color="#ff6600" />
|
||||||
icon={Handshake}
|
|
||||||
title={t('walletUi.supporters.partners')}
|
|
||||||
subtitle={t('walletUi.supporters.partnersDesc')}
|
|
||||||
color="#ff6600"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{PARTNERS.length > 0 ? (
|
{PARTNERS.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
{PARTNERS.map((p) => (
|
{PARTNERS.map((p) => <PartnerCard key={p.name} partner={p} />)}
|
||||||
<PartnerCard key={p.name} partner={p} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="rounded-xl border border-dashed border-[#ff6600]/20 p-5 text-center text-sm text-gray-500">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-4">
|
{t('walletUi.supporters.wantPartner')}
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<PlaceholderCard key={i} label={t('walletUi.supporters.comingSoon')} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-dashed border-[#ff6600]/20 p-6 text-center">
|
|
||||||
<p className="text-gray-500 text-sm">{t('walletUi.supporters.wantPartner')}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── 8. Friends ── */}
|
|
||||||
<section>
|
<section>
|
||||||
|
<SectionHeader icon={Star} title={t('walletUi.supporters.friends')} subtitle={t('walletUi.supporters.friendsDesc')} color="#ccff00" />
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{FRIENDS.map((f) => <FriendCard key={f.name} friend={f} />)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-xl border border-dashed border-[#ccff00]/20 p-4 text-center">
|
||||||
|
<p className="text-xs text-gray-500">{t('walletUi.supporters.wantListed')}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-gray-600">{t('walletUi.supporters.wantListedDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Grantmakers ── */}
|
||||||
|
<TabsContent value="grants" className="space-y-4 pt-4">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={Star}
|
icon={Handshake}
|
||||||
title={t('walletUi.supporters.friends')}
|
title={t('walletUi.supporters.grantsSection')}
|
||||||
subtitle={t('walletUi.supporters.friendsDesc')}
|
subtitle={t('walletUi.supporters.grantsSectionDesc')}
|
||||||
color="#ccff00"
|
color="#f7931a"
|
||||||
/>
|
/>
|
||||||
|
<GrantmakerForm />
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
</TabsContent>
|
||||||
{FRIENDS.map((f) => (
|
</Tabs>
|
||||||
<FriendCard key={f.name} friend={f} />
|
|
||||||
))}
|
|
||||||
{/* Fill remaining slots up to 5 with placeholders */}
|
|
||||||
{FRIENDS.length < 5 && Array.from({ length: 5 - FRIENDS.length }).map((_, i) => (
|
|
||||||
<PlaceholderCard key={`ph-${i}`} label={t('walletUi.supporters.comingSoon')} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 rounded-xl border border-dashed border-[#ccff00]/20 p-5 text-center">
|
|
||||||
<p className="text-gray-500 text-sm mb-1">{t('walletUi.supporters.wantListed')}</p>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
{t('walletUi.supporters.wantListedDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Mission ── */}
|
|
||||||
<section>
|
|
||||||
<div className="relative rounded-2xl overflow-hidden border-2 border-[#ff0040]/30 bg-black/80 p-8 md:p-10">
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
|
|
||||||
<div className="flex justify-center mb-5">
|
|
||||||
<Heart className="h-10 w-10 text-[#ff0040]" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-black uppercase text-center mb-3">
|
|
||||||
{t('walletUi.supporters.whyTitle1')} <span className="neon-red">{t('walletUi.supporters.whyTitle2')}</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-400 text-sm text-center max-w-2xl mx-auto mb-6 leading-relaxed">
|
|
||||||
{t('walletUi.supporters.whyDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
{[
|
|
||||||
{ emoji: '🛹', title: t('walletUi.supporters.why1Title'), desc: t('walletUi.supporters.why1Desc') },
|
|
||||||
{ emoji: '⚡', title: t('walletUi.supporters.why2Title'), desc: t('walletUi.supporters.why2Desc') },
|
|
||||||
{ emoji: '🌍', title: t('walletUi.supporters.why3Title'), desc: t('walletUi.supporters.why3Desc') },
|
|
||||||
].map(({ emoji, title, desc }) => (
|
|
||||||
<div key={title} className="p-4 rounded-xl bg-white/5 border border-white/10 text-center">
|
|
||||||
<div className="text-3xl mb-2">{emoji}</div>
|
|
||||||
<p className="font-bold text-sm mb-1">{title}</p>
|
|
||||||
<p className="text-xs text-gray-500">{desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── Footer ── */}
|
||||||
<div className="border-t border-gray-800 pt-8 text-center space-y-2 text-sm text-gray-500">
|
<div className="border-t border-gray-800 pt-5 text-center text-xs text-gray-500">
|
||||||
<p>{t('walletUi.supporters.thankYou')}</p>
|
<p>{t('walletUi.supporters.thankYou')}</p>
|
||||||
<p>
|
<p className="mt-1.5">
|
||||||
<Link to="/license" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">{t('walletUi.supporters.license')}</Link>
|
<Link to="/license" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">{t('walletUi.supporters.license')}</Link>
|
||||||
{' '}·{' '}
|
{' '}·{' '}
|
||||||
<Link to="/about" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">{t('nav.about')}</Link>
|
<Link to="/about" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">{t('nav.about')}</Link>
|
||||||
{' '}·{' '}
|
{' '}·{' '}
|
||||||
<Link to="/faq" className="text-[#ccff00]/70 hover:text-[#ccff00] transition-colors underline">{t('walletUi.supporters.faq')}</Link>
|
<Link to="/faq" className="text-[#ccff00]/70 underline transition-colors hover:text-[#ccff00]">{t('walletUi.supporters.faq')}</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,20 @@ export default function TrustPage() {
|
||||||
<p>{t('trustUi.povDesc')}</p>
|
<p>{t('trustUi.povDesc')}</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex flex-col items-center gap-3 pt-2">
|
||||||
<Link to="/settings">
|
<Link to="/settings">
|
||||||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
||||||
{t('trustUi.settingsCta')}
|
{t('trustUi.settingsCta')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://git.skate-spots.org/skatespots/SkateSpots/blob/main/docs/THREAT_MODEL.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-gray-500 underline decoration-dotted underline-offset-2 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
{t('common.learnMore')}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,18 @@ export default defineConfig(() => ({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
// Capacitor keeps the map eager (build-time alias), while the web build
|
||||||
"@": path.resolve(__dirname, "./src"),
|
// lazy-loads it to keep the initial entry chunk smaller. Both modules
|
||||||
},
|
// resolve to the same MapPage; this only changes STATIC vs DYNAMIC import.
|
||||||
|
alias: [
|
||||||
|
...(process.env.CAPACITOR_BUILD
|
||||||
|
? [{
|
||||||
|
find: '@/pages/HomePage',
|
||||||
|
replacement: path.resolve(__dirname, './src/pages/HomePage.native.tsx'),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['@lightninglabs/lnc-web', '@xenova/transformers'],
|
exclude: ['@lightninglabs/lnc-web', '@xenova/transformers'],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue