Compare commits

..

171 commits

Author SHA1 Message Date
SkateSpots
3f4fa29d55 fix: send origin referrer to OpenStreetMap
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-02 00:36:23 +07:00
SkateSpots
4e7faed781 release: 1.7.83
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-02 00:30:52 +07:00
SkateSpots
68119eb927 release: 1.7.82
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-02 00:18:03 +07:00
SkateSpots
579dcddc1d fix: restore orange brand icon and harden OSM tile cache
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 23:32:29 +07:00
SkateSpots
f75bad3e8d fix: keep legacy social preview path on current artwork
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 21:48:11 +07:00
SkateSpots
4c4368654a fix: refresh social preview card copy
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 21:44:22 +07:00
SkateSpots
c352986e70 fix: add a proper 1200x630 social preview card
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 21:29:39 +07:00
SkateSpots
11171b37da docs: publish 1.7.81 download page
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 21:11:27 +07:00
SkateSpots
62558efa59 release: 1.7.81 2026-11-01 21:10:55 +07:00
SkateSpots
1af3a5f6ef feat: improve Bitcoin grantmaker inquiry UX
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 20:37:35 +07:00
SkateSpots
9732743527 fix: retire deprecated relay and use canonical community invite URL
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 20:22:13 +07:00
SkateSpots
50d82d5a05 docs: publish 1.7.80 download page
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 20:09:13 +07:00
SkateSpots
2a52d10f26 release: 1.7.80 2026-11-01 20:08:03 +07:00
SkateSpots
327e81f155 feat: marketplace UX and resilience pass 2026-11-01 20:05:21 +07:00
SkateSpots
3f28c3fdb4 security: rotate CVM identity and externalize signing
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 17:22:15 +07:00
SkateSpots
ab90e02f69 docs: publish 1.7.79 nsec and deep-link fix
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 13:55:35 +07:00
SkateSpots
d0a593ee67 release: 1.7.79 2026-11-01 13:52:05 +07:00
SkateSpots
5e90b828b4 refactor: use app-owned secure login provider 2026-11-01 13:49:06 +07:00
SkateSpots
fed2ff135b security: keep nsec session-only and fix direct links 2026-11-01 13:48:07 +07:00
SkateSpots
722ca1bc7e docs: publish 1.7.78 wallet-first polish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 13:31:56 +07:00
SkateSpots
66928b2bca release: 1.7.78 2026-11-01 13:28:24 +07:00
SkateSpots
4c603a8126 ui: lead with built-in wallet on native 2026-11-01 13:25:22 +07:00
SkateSpots
a42e85202f docs: publish 1.7.77 wallet polish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 13:12:38 +07:00
SkateSpots
b71ff5b08f release: 1.7.77 2026-11-01 13:09:06 +07:00
SkateSpots
c846dcc267 ui: simplify wallet connection flow 2026-11-01 13:06:01 +07:00
SkateSpots
03d13c0175 docs: publish 1.7.76 PWA polish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 12:56:37 +07:00
SkateSpots
2e904c287e release: 1.7.76 2026-11-01 12:53:38 +07:00
SkateSpots
5bb5b5efc6 ui: fix PWA icons and mobile signup viewport 2026-11-01 12:50:37 +07:00
SkateSpots
d080ca75f4 docs: publish 1.7.75 coordinator routing fix
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 12:40:59 +07:00
SkateSpots
c21d1b097d release: 1.7.75 2026-11-01 12:37:29 +07:00
SkateSpots
4e44ce769b fix: support custom coordinator request routing and device-link errors 2026-11-01 12:34:27 +07:00
SkateSpots
7e8d13cbff docs: publish 1.7.74 coordinator installer
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 12:21:07 +07:00
SkateSpots
6ec5a9f024 release: 1.7.74 2026-11-01 12:17:25 +07:00
SkateSpots
f7316060ae feat: one-command crew coordinator installer 2026-11-01 12:14:17 +07:00
SkateSpots
ae1d7275e6 fix: avoid false community-bot outage alerts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 11:24:01 +07:00
SkateSpots
719a6791f9 release: 1.7.73 2026-11-01 11:19:11 +07:00
SkateSpots
53380a99e1 ui: simplify adaptive sign-in choices 2026-11-01 11:16:05 +07:00
SkateSpots
807b48bafc docs: publish 1.7.72 security hardening
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 01:46:15 +07:00
SkateSpots
78741f9517 release: 1.7.72 2026-11-01 01:42:27 +07:00
SkateSpots
7b2fd3993a security: verify relay events and stop image referrer leaks 2026-11-01 01:39:32 +07:00
SkateSpots
b180d136c3 docs: publish 1.7.71 status polish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-11-01 01:24:43 +07:00
SkateSpots
42ffcb1084 release: 1.7.71 2026-11-01 01:21:03 +07:00
SkateSpots
f041399fe9 ui: make desktop status compact and non-overlapping 2026-11-01 01:17:45 +07:00
SkateSpots
56d318811f docs: publish 1.7.70 Amber privacy fix
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 17:25:26 +07:00
SkateSpots
0859327464 release: 1.7.70 2026-10-31 17:21:43 +07:00
SkateSpots
53cad29c7e fix: gate Amber inbox decryption and expire redirect ops 2026-10-31 17:17:08 +07:00
SkateSpots
5acaa3b2b4 docs: publish 1.7.69 status polish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 17:06:03 +07:00
SkateSpots
226d0c3f95 release: 1.7.69 2026-10-31 17:00:24 +07:00
SkateSpots
f646d5aa14 ui: quiet and compact desktop status control 2026-10-31 16:57:42 +07:00
SkateSpots
0fe82c28d8 release: 1.7.68 2026-10-31 16:52:48 +07:00
SkateSpots
20de6ba8e1 feat: verify coordinator RPC and operator identity 2026-10-31 16:49:38 +07:00
SkateSpots
bcc277a054 docs: publish 1.7.67 coordinator kit
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 16:34:53 +07:00
SkateSpots
c8f471b290 release: 1.7.67 2026-10-31 16:29:05 +07:00
SkateSpots
c2750eae85 feat: production crew-owned Cordn coordinator kit 2026-10-31 16:25:52 +07:00
SkateSpots
609b15be08 docs: publish 1.7.66 safety and escrow primitive
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 15:24:07 +07:00
SkateSpots
cc0545cc5c release: 1.7.66 2026-10-31 15:16:15 +07:00
SkateSpots
31d1630c50 feat: high-value safety, reputation decay, escrow 2-of-3 primitive 2026-10-31 15:13:02 +07:00
SkateSpots
1d810ea964 docs: publish 1.7.65 diagnostics release
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 12:18:26 +07:00
SkateSpots
a813e1e7f7 release: 1.7.65 2026-10-31 12:14:45 +07:00
SkateSpots
618d961f72 feat: relay consistency diagnostic and recent reputation 2026-10-31 12:11:40 +07:00
SkateSpots
e1a011045a docs: publish 1.7.64 safety note
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 10:44:36 +07:00
SkateSpots
a44665f05f release: 1.7.64 2026-10-31 10:40:21 +07:00
SkateSpots
4392df3202 feat(market): add low-friction new-seller safety note 2026-10-31 10:37:23 +07:00
SkateSpots
99f04edf5d docs: publish 1.7.63 privacy release
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 10:01:22 +07:00
SkateSpots
3edef9cbdf release: 1.7.63 2026-10-31 09:57:30 +07:00
SkateSpots
42d25818b4 fix: deterministic relay merge and privacy hardening 2026-10-31 09:54:36 +07:00
SkateSpots
5a1d849681 docs: add 1.7.62 F-Droid changelog
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 09:36:46 +07:00
SkateSpots
b4fbae693b docs: publish 1.7.62 download page
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 09:31:41 +07:00
SkateSpots
c08d5d4baa release: 1.7.62 2026-10-31 09:27:52 +07:00
SkateSpots
2c8bd594a4 fix: harden badge links and polish small flows 2026-10-31 09:24:32 +07:00
SkateSpots
9ce57bedf9 docs: remove external submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:18:58 +07:00
SkateSpots
bfc777c18b docs: make distribution Nostr-only, no external identities 2026-10-30 23:17:54 +07:00
SkateSpots
a2b732317e dist: point F-Droid recipe at splash-tracked source
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:11:15 +07:00
SkateSpots
5ebf741427 build: track Android splash source for clean checkouts 2026-10-30 23:09:23 +07:00
SkateSpots
4ae30ee8e6 dist: prepare unsigned F-Droid build output 2026-10-30 23:07:42 +07:00
SkateSpots
72e5f89b49 docs: add external store submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:04:13 +07:00
SkateSpots
d003748bb1 dist: use valid F-Droid category
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:02:17 +07:00
SkateSpots
6048eafc0d docs: record future distribution and external submission limits
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:59:06 +07:00
SkateSpots
562a7de7c0 docs: record Forgejo release helper
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:53:00 +07:00
SkateSpots
8b12001fa6 docs: document self-hosted F-Droid republish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:51:09 +07:00
SkateSpots
383ff8a5cf dist: add Forgejo release helper and F-Droid instructions
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:49:23 +07:00
SkateSpots
65914144d1 dist: document self-hosted Forgejo and F-Droid repo
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:43:47 +07:00
SkateSpots
a8780d5236 dist: self-hosted Forgejo release page and F-Droid metadata 2026-10-30 22:33:53 +07:00
SkateSpots
2dcf5a2aad docs: record 1.7.61 Cordn setup timeline
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:25:18 +07:00
SkateSpots
50c95d0ff7 release: 1.7.61 2026-10-30 22:21:24 +07:00
SkateSpots
4ff736734d ui(cordn): show setup progress through the join request 2026-10-30 22:18:11 +07:00
SkateSpots
fd1713f6b1 docs: record 1.7.60 Cordn and payment polish 2026-10-30 22:07:49 +07:00
SkateSpots
6c9b6fab02 release: 1.7.60 2026-10-30 22:03:46 +07:00
SkateSpots
0ae4c59630 feat(ux): cordn status line, payment readiness and image compression 2026-10-30 22:00:31 +07:00
SkateSpots
ab11b0d3ed docs: record 1.7.59 owner delete and mobile back 2026-10-30 21:28:21 +07:00
SkateSpots
0355c10dbc release: 1.7.59 2026-10-30 21:24:26 +07:00
SkateSpots
28d34e3fbe fix(ui): owner spot delete, mobile back and simplified account menu 2026-10-30 21:21:00 +07:00
SkateSpots
b0df599b52 docs: record 1.7.58 brand profile shortcut 2026-10-30 21:12:14 +07:00
SkateSpots
74bd140295 release: 1.7.58 2026-10-30 21:08:15 +07:00
SkateSpots
42f47304dd ui(header): brand lockup opens the user profile when signed in 2026-10-30 21:04:47 +07:00
SkateSpots
c00bffdf78 docs: record 1.7.57 avatar profile shortcut 2026-10-30 20:58:40 +07:00
SkateSpots
5026b63323 release: 1.7.57 2026-10-30 20:54:42 +07:00
SkateSpots
601393ece3 ui(header): make the user avatar open the profile 2026-10-30 20:51:21 +07:00
SkateSpots
ee6db357c2 docs: record 1.7.56 navigation simplification 2026-10-30 20:33:42 +07:00
SkateSpots
cdf4a63809 release: 1.7.56 2026-10-30 20:29:12 +07:00
SkateSpots
23929653d3 ui(nav): simplify mobile bar to map, three tabs and more 2026-10-30 20:25:52 +07:00
SkateSpots
344b41afc8 docs: record 1.7.55 operator dashboard and TLS hardening 2026-10-30 18:47:43 +07:00
SkateSpots
52a84479d4 release: 1.7.55 2026-10-30 18:43:52 +07:00
SkateSpots
f71f5eeef8 feat(ops): signed read-only relay status dashboard + HSTS/CAA hardening 2026-10-30 18:40:32 +07:00
SkateSpots
1f27311903 docs: record 1.7.54 Blossom quotas 2026-10-30 18:16:41 +07:00
SkateSpots
465683cf71 release: 1.7.54 2026-10-30 18:12:56 +07:00
SkateSpots
a4f72a3d05 feat(media): image-only per-npub Blossom quotas and upload proxy 2026-10-30 18:09:44 +07:00
SkateSpots
c4a1671f81 fix(ops): avoid false Cordn announce alerts from SIGPIPE 2026-10-30 17:44:59 +07:00
SkateSpots
20b2b0d676 docs: record 1.7.53 Cordn reliability and relay limits 2026-10-30 17:43:06 +07:00
SkateSpots
95ff486038 release: 1.7.53 2026-10-30 17:39:13 +07:00
SkateSpots
7791cbe9fa fix(cordn): back off on relay pressure and surface relay health 2026-10-30 17:35:59 +07:00
SkateSpots
26a8c5eb31 docs: record 1.7.52 support page redesign 2026-10-30 17:20:17 +07:00
SkateSpots
69a86493e8 release: 1.7.52 2026-10-30 17:16:40 +07:00
SkateSpots
f2787d79cf ui(support): compact tabbed support page with friendlier /support alias 2026-10-30 17:13:25 +07:00
SkateSpots
a1b069da13 docs: record 1.7.51 profile payment polish 2026-10-30 13:43:04 +07:00
SkateSpots
c65e6effb5 release: 1.7.51 2026-10-30 13:38:33 +07:00
SkateSpots
b39c2ec386 fix(payments): round out profile CLINK and Lightning methods 2026-10-30 13:35:18 +07:00
SkateSpots
22eb46c62b docs: record 1.7.50 performance and navigation release 2026-10-30 13:19:09 +07:00
SkateSpots
65a7f0bdbb release: 1.7.50 2026-10-30 13:15:18 +07:00
SkateSpots
f95adeecf1 perf: faster shell, lazy wallet stack, serialized Amber and navigation polish 2026-10-30 13:12:06 +07:00
SkateSpots
f8a1054894 docs: record 1.7.49 Cordn setup fix 2026-10-30 10:31:40 +07:00
SkateSpots
7958ca59bc release: 1.7.49 2026-10-30 10:28:12 +07:00
SkateSpots
68b77d6c14 fix(amber): serialize Cordn setup and explain the two approvals 2026-10-30 10:25:04 +07:00
SkateSpots
8a347b4435 docs: record 1.7.48 Amber cancellation fix 2026-10-30 10:14:58 +07:00
SkateSpots
3d905af6ec release: 1.7.48 2026-10-30 10:11:29 +07:00
SkateSpots
1c361b8df5 fix(amber): stop decrypt loops after navigation 2026-10-30 10:08:19 +07:00
SkateSpots
20ff45089f docs: record 1.7.47 faster splash 2026-10-30 09:37:17 +07:00
SkateSpots
e22e2ec85f release: 1.7.47 2026-10-30 09:33:49 +07:00
SkateSpots
dbc78a1760 feat: faster splash with static logo and aperture exit 2026-10-30 09:30:50 +07:00
SkateSpots
0175645f3a docs: record 1.7.46 splash transition 2026-10-30 09:09:07 +07:00
SkateSpots
008fb81f73 release: 1.7.46 2026-10-30 09:06:20 +07:00
SkateSpots
9838aa6d72 feat: smoother native-to-splash handoff and app reveal 2026-10-30 09:03:13 +07:00
SkateSpots
a9dffbf09d docs: record 1.7.44/1.7.45 native boot fixes 2026-10-30 01:26:11 +07:00
SkateSpots
afd5a09acd release: 1.7.45 2026-10-30 01:22:38 +07:00
SkateSpots
e234253870 fix: eager-load the map and harden native boot cleanup 2026-10-30 01:19:19 +07:00
SkateSpots
c66aeb526f release: 1.7.44 2026-10-30 01:12:14 +07:00
SkateSpots
267e93abc7 fix: never abort native boot on service-worker cleanup 2026-10-30 01:08:58 +07:00
SkateSpots
9dd2231332 docs: record 1.7.43 boot-loop breaker 2026-10-30 01:02:45 +07:00
SkateSpots
1a88f8637d release: 1.7.43 2026-10-30 00:59:24 +07:00
SkateSpots
b95371a123 fix: break native boot reload loops before they trap the app 2026-10-30 00:56:19 +07:00
SkateSpots
b76891798e docs: record 1.7.41/1.7.42 native boot recovery 2026-10-30 00:46:36 +07:00
SkateSpots
7dc8c2c08b release: 1.7.42 2026-10-30 00:43:31 +07:00
SkateSpots
822cca23d7 fix: make native boot background black and bust stale boot guard cache 2026-10-30 00:40:18 +07:00
SkateSpots
c36bf2f029 release: 1.7.41 2026-10-30 00:34:46 +07:00
SkateSpots
443c7122d8 fix: version and bypass app-shell worker during native boot 2026-10-30 00:31:40 +07:00
SkateSpots
2d9758593a docs: record 1.7.39/1.7.40 audit and native boot fix 2026-10-30 00:28:15 +07:00
SkateSpots
44be542366 release: 1.7.40 2026-10-30 00:23:46 +07:00
SkateSpots
7719be12d9 fix: clear stale native app-shell service workers before boot 2026-10-30 00:20:32 +07:00
SkateSpots
6aba9b356f release: 1.7.39 2026-10-30 00:13:39 +07:00
SkateSpots
7a928dae6d feat: profile Lab services strip and dependency-light XLSX export 2026-10-30 00:10:29 +07:00
SkateSpots
bf47d43674 docs: record 1.7.38 audit fixes 2026-10-29 23:21:27 +07:00
SkateSpots
41f420ae55 release: 1.7.38 2026-10-29 23:17:30 +07:00
SkateSpots
262eb32e7c chore: audit fixes, designer stats and form FAB handling 2026-10-29 23:14:12 +07:00
SkateSpots
dddfe51f86 docs: record 1.7.37 Cordn empty-response fix 2026-10-29 23:00:28 +07:00
SkateSpots
b94ac3d409 release: 1.7.37 2026-10-29 22:56:49 +07:00
SkateSpots
eaf345e2ec fix: tolerate empty Cordn coordinator tool responses 2026-10-29 22:53:36 +07:00
SkateSpots
96503b672c docs: record 1.7.36 Transport Lab craft 2026-10-29 22:42:07 +07:00
SkateSpots
b0f6dd9f77 release: 1.7.36 2026-10-29 22:37:47 +07:00
SkateSpots
6746353670 feat: add Transport Lab craft with self-attested fleet profiles 2026-10-29 22:34:28 +07:00
SkateSpots
096f2155fe docs: record 1.7.34/1.7.35 stat fit and map footer 2026-10-29 21:43:46 +07:00
SkateSpots
5d40a67a8a release: 1.7.35 2026-10-29 21:39:31 +07:00
SkateSpots
569ad9b01c fix: replace floating map links pill with slim footer and fit follower stats 2026-10-29 21:35:45 +07:00
SkateSpots
d3d82f7d46 release: 1.7.34 2026-10-29 21:28:56 +07:00
SkateSpots
4a4f398df0 fix: fit follower stats and add grantmaker transparency 2026-10-29 21:25:08 +07:00
SkateSpots
957033e678 docs: record sponsorship opt-in and grantmaker form 2026-10-29 21:09:50 +07:00
SkateSpots
7f29a822ab release: 1.7.33 2026-10-29 21:01:36 +07:00
SkateSpots
3f35a6f3a4 feat: sponsorship opt-in and Bitcoin grantmaker inquiry 2026-10-29 20:57:43 +07:00
SkateSpots
8a6968735c docs: record 1.7.32 sponsor-ready profiles 2026-10-29 20:32:51 +07:00
SkateSpots
d921a6c090 release: 1.7.32 2026-10-29 20:24:40 +07:00
SkateSpots
bc1987f78c feat: sponsor-ready profiles with follow, events and audience stats 2026-10-29 20:20:49 +07:00
SkateSpots
9c22bd78f8 docs: record 1.7.31 Cordn UX, outbox and splash 2026-10-29 19:31:40 +07:00
329 changed files with 11409 additions and 1522 deletions

3
.gitignore vendored
View file

@ -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

458
AGENTS.md
View file

@ -659,6 +659,10 @@ session with **no other memory of this project**. So:
calls are suppressed so partial JSON never renders. Pollinations' `mistral` calls are suppressed so partial JSON never renders. Pollinations' `mistral`
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` + slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
`getCommunityStats` cover **all** Lab roles (kinds 35001–35014). `getCommunityStats` cover **all** Lab roles (kinds 35001–35014).
**Form-page visibility (2026-10-29):** the assistant is deliberately hidden on
`/become/*`, `/create*`, `/edit*`, `/supporters` and `/onboarding` — the
floating bubble covered form inputs/chips (especially on mobile). It remains
available everywhere else.
**Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer **Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer
pulses forever — the `animate-ping` halo shows **only when an unread tip pulses forever — the `animate-ping` halo shows **only when an unread tip
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes** exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
@ -906,6 +910,16 @@ session with **no other memory of this project**. So:
burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that
aren't hard-coded**, so Marketplace/Mints (and any future section) are always aren't hard-coded**, so Marketplace/Mints (and any future section) are always
reachable on mobile — don't re-hardcode only a subset. reachable on mobile — don't re-hardcode only a subset.
- **Transport / Driver Lab craft (2026-10-29):** kind `35016`
(`skatetransport`) is a data-driven `BECOME_ROLES.transport` craft over
`CraftProfileForm`. Vehicles (`car/taxi/van/moto/helicopter/plane/boat/other`),
services (`people/goods/gear/airport/venue/tours/logistics/other`),
capabilities, capacity and coverage are `t` tags; extras are `capacity`,
`cargo`, `coverage`, `availability`, `base`, `accessibility`. `LabPage` has a
Transport tab + filters, `CoachDetailPage` renders the shared trust/vouch/
review blocks and a prominent **self-attested** notice. No licence, insurance,
roadworthiness or operator permit is verified — never label it verified.
`useGrowthStats.totalTransport` / `/stats` and Supporters impact include it.
- **DIY resources (`/diy`, 2026-10-05):** a hub for making your own - **DIY resources (`/diy`, 2026-10-05):** a hub for making your own
boards/ramps/concrete. Content is a seed list in **`src/lib/diyGuides.ts`** boards/ramps/concrete. Content is a seed list in **`src/lib/diyGuides.ts`**
(`DIY_CATEGORIES` boards/ramps/concrete/repairs/tools + `DIY_CARDS` + (`DIY_CATEGORIES` boards/ramps/concrete/repairs/tools + `DIY_CARDS` +
@ -1174,6 +1188,17 @@ session with **no other memory of this project**. So:
(`NIP19Page`): the app badge in the header, user badges in a "Badges" card. (`NIP19Page`): the app badge in the header, user badges in a "Badges" card.
⚠️ `NostrMetadata` has no `badges` field, so it's read via a cast ⚠️ `NostrMetadata` has no `badges` field, so it's read via a cast
(`metadata as … & Record<string, unknown>`), like `sponsors`/`cordn` before it. (`metadata as … & Record<string, unknown>`), like `sponsors`/`cordn` before it.
- **Sponsor-ready profile (2026-10-29):** `NIP19Page` now has Follow/Unfollow,
Followers/Following, **Events hosted** (`useUserEvents`), a six-cell stats
strip and a neutral **For sponsors & collabs** card (followers/events/spots/
zaps, prefilled collab DM, Rider Card link). The card only appears when the
profile has actual skate activity. NIP-05 has a loading/verifying state.
`useFollowing` invalidates follower counts on follow/unfollow. Sponsorship
is an **explicit opt-in** (`sponsorships_open` + optional
`sponsorships_note` in kind-0), never inferred from activity; the neutral card
is shown only when the profile has skate activity or opted in. `/supporters`
has a Bitcoin-grantmaker inquiry form that composes a private DM to the owner
(no storage) and a humorous KYC disclosure.
- **Report → action + a publish floor (2026-09-28):** the one-tap "not a skate - **Report → action + a publish floor (2026-09-28):** the one-tap "not a skate
spot" report used to only land in the admin queue (invisible effect). It now spot" report used to only land in the admin queue (invisible effect). It now
**shows on the spot page to everyone** ("Reported as not a skate spot by N — **shows on the spot page to everyone** ("Reported as not a skate spot by N —
@ -1393,6 +1418,24 @@ session with **no other memory of this project**. So:
it in when known. it in when known.
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0 **Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
**`cordn`** field (app convention, documented in NIP.md — not a new kind). **`cordn`** field (app convention, documented in NIP.md — not a new kind).
**Crew-owned coordinator kit (2026-10-31, install helper 2026-11-01):**
`deploy/cordn/install.sh` is the one-command installer (generates `.env`,
starts SQLite, prints nprofile/add link). `deploy/cordn/` remains the
production hand-off for crews: Docker Compose, SQLite volume, `.env` template,
backup/restore scripts and a runbook. Cordn connects **outbound** to ContextVM
relays; no inbound port, domain or TLS terminator is required for the
coordinator itself. The app still does **not** embed a coordinator. Settings →
Coordinator links to the kit; the crew's `coordinator` tag/operator remains
the adoption path. Keep the coordinator key separate from crew/admin keys.
Do not claim multi-coordinator failover; this is one trusted delivery service
with backups, not BFT.
**Custom coordinator routing (2026-11-01):** `useCordnNative` treats a crew's
own coordinator as authoritative. A custom URL without a service pubkey is
rejected instead of silently falling back to the SkateSpots coordinator. The
join watcher groups crews by coordinator pubkey/relay and polls each one; a
custom coordinator being offline does not stop notifications for other crews.
Device-link creation now shows an inline error/retry instead of a permanent
loading dialog.
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex / `EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
http URL), **normalises a bare pointer to Cordn's canonical add-link** before http URL), **normalises a bare pointer to Cordn's canonical add-link** before
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page` saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
@ -1592,6 +1635,16 @@ session with **no other memory of this project**. So:
tombstone, not a relay hard-delete; automatic read receipts are silent-signer tombstone, not a relay hard-delete; automatic read receipts are silent-signer
only (Amber would prompt). Live benchmark (`scripts/cordn-benchmark.ts`, 30 only (Amber would prompt). Live benchmark (`scripts/cordn-benchmark.ts`, 30
msgs): ~489ms/send, ~20ms/fetch, snapshot ~13.8KB (~460 B/message). msgs): ~489ms/send, ~20ms/fetch, snapshot ~13.8KB (~460 B/message).
- **Cordn room UX + resilience (2026-10-29):** `/messages` has a **Private
rooms** section with Community + every crew MLS room, Open/Set up state, and
local unread dots (`hooks/useCordnRoomRead.ts` + `lib/cordnRoomRead.ts`; only
rooms actually opened record `latestAt/seenAt`, so a dot is never invented).
The panel has per-room mute (`useDmMute` key `cordn:<gid>`), in-room search,
Chat-vs-Private guidance, coordinator **last checked**, and confirmations for
approve/remove. Failed sends persist in a per-account/room device-local outbox
(`lib/cordnOutbox.ts`) and auto-retry up to 3 times when online, then stay for
explicit Retry/Discard. Background unread for rooms never opened still needs a
global poller; there is deliberately no background decrypt.
- **Cordn join-request push (2026-10-28):** `scripts/cordn-join-watcher.ts` - **Cordn join-request push (2026-10-28):** `scripts/cordn-join-watcher.ts`
runs every minute (user timer) and polls the coordinator for known crew gids. runs every minute (user timer) and polls the coordinator for known crew gids.
New requests become (a) a **NIP-17 DM** to the founder/admins (push service New requests become (a) a **NIP-17 DM** to the founder/admins (push service
@ -1603,11 +1656,13 @@ session with **no other memory of this project**. So:
**custom coordinator** are skipped (only the SkateSpots embedded coordinator is **custom coordinator** are skipped (only the SkateSpots embedded coordinator is
queried); approved requests leave the coordinator but the bell card remains queried); approved requests leave the coordinator but the bell card remains
until dismissed. until dismissed.
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map - **Map site-links strip (2026-10-05; mobile bar 2026-10-29):** the desktop footer
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links is hidden on the map (full-bleed), so `components/MapFooterStrip.tsx` surfaces
(DIY/Newsletter/License/Privacy + "not sponsored by OpenSats") as an overlay the same links (DIY/Newsletter/License/Privacy + "not sponsored by OpenSats")
inside the map container: a **slim full-width bar on desktop** and a **tiny as an overlay inside the map container. Desktop is a slim full-width bar;
collapsed "Links" chip on mobile** (expands to a compact scrollable row). mobile is now a **slim always-visible strip above the bottom nav** (the old
floating “Links” chip in the centre was removed — it collided visually with
the Events/Crews nav items and its popover looked broken).
**Always visible (2026-10-28):** the old dismissible `useMapFooterHidden` **Always visible (2026-10-28):** the old dismissible `useMapFooterHidden`
behavior/setting was removed — it was easy to hide the only footer on the map behavior/setting was removed — it was easy to hide the only footer on the map
forever and then report it missing. The map's OpenStreetMap attribution moved forever and then report it missing. The map's OpenStreetMap attribution moved
@ -1744,6 +1799,17 @@ session with **no other memory of this project**. So:
`public/images/screenshots/{map,spot,community}.png` (1280×800, quantised — `public/images/screenshots/{map,spot,community}.png` (1280×800, quantised —
captured with headless Chromium; regenerate with a CDP harness if the UI captured with headless Chromium; regenerate with a CDP harness if the UI
changes materially). changes materially).
- **Nsec storage hardening (2026-11-01):** `src/lib/nostrLogin.ts` replaces the
package login provider. Non-secret logins stay in localStorage; **nsec logins
are sessionStorage-only** and are also stripped from localStorage on the next
write. A tab reload keeps the nsec session; closing the browser/app requires
re-entry. Use Amber or a bunker to stay signed in. `src/lib/nostrLogin.test.tsx`
asserts raw nsec never lands in persistent localStorage. Do not import
`@nostrify/react/login` directly again — use `@/lib/nostrLogin`.
- **Direct-link boot rewrite (2026-11-01):** `main.tsx` now rewrites any
non-root pathname without a hash into `/#<path>` before React mounts, so
`/spot/...`, `/crew/...`, `/event/...`, `/listing/...` and cold native App
Links do not silently land on the map.
- **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native - **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native
auth upgrades (owner picked these two; read-only/npub sessions were explicitly auth upgrades (owner picked these two; read-only/npub sessions were explicitly
declined). declined).
@ -2189,9 +2255,9 @@ session with **no other memory of this project**. So:
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) — - **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
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-09-27): `npm test` → **0 errors, 0 warnings**, - **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
**101 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.5.4**. (`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.79**.
- ✅ 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`,
@ -2339,8 +2405,14 @@ The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped o
### Android release build ### Android release build
- `npm run android:build` → `android/app/build/outputs/apk/release/app-release.apk`. - `npm run android:build` → `android/app/build/outputs/apk/release/app-release.apk`.
- **Versioning**: `package.json` `version` is the single source of truth; `scripts/sync-android-version.mjs` syncs it to `versionName`/`versionCode` (e.g. `1.4.5` → `10405`) during the build. Bump **only** `package.json`. - **Versioning**: `package.json` `version` is the single source of truth; `scripts/sync-android-version.mjs` syncs it to `versionName`/`versionCode` (e.g. `1.4.5` → `10405`) during the build. Bump **only** `package.json`.
- **Signing**: secrets live in the gitignored `keystore.properties`; `scripts/inject-signing-config.mjs` injects a managed block into `android/app/build.gradle` (runs in `android:init`/`android:sync`/`android:build`). The authoritative keystore is **`/home/sovereignsk8/skatespots-release.jks`** (alias `skatespots`). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`. - **Signing**: secrets live **outside the repo** at `~/.config/skatespots/android/keystore.properties`; `scripts/inject-signing-config.mjs` injects a managed block into `android/app/build.gradle` (runs in `android:init`/`android:sync`/`android:build`). The authoritative keystore is **`~/.config/skatespots/android/skatespots-release.jks`** (alias `skatespots`, mode 600). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`. Never put signing material back in the repo: Vite serves files by filesystem path, not by `.gitignore`.
- `android/` is **generated and gitignored**; recreate with `npm run android:init`. - `android/` is **generated and gitignored**; recreate with `npm run android:init`.
- **Launch splash:** `resources/splash.png` is the orange brand wheel. Capacitor's
generated drawables used a stock blue placeholder, so `scripts/make-android-splash.mjs`
(sharp) regenerates all portrait/landscape densities from the tracked source;
`android:init`/`android:sync`/`android:build` all run it. The web splash
(`components/SplashScreen.tsx`) uses `public/images/icon-1024.png` with a roll-in,
rolling spin and CSS sparks; `prefers-reduced-motion` falls back to a static mark.
- Preflight: `cd android && ./gradlew :app:signingReport` confirms the signing config resolves. - Preflight: `cd android && ./gradlew :app:signingReport` confirms the signing config resolves.
### Version bump + release notes ### Version bump + release notes
@ -2372,6 +2444,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.79). It is a **third-party repo**, not
F-Droid main. Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`.
The developer APK signature is unchanged; the repo key only signs indexes.
A backup of the repo keystore/config is in
`~/.config/skatespots/fdroid-repo/` (mode 600).
- **F-Droid main/IzzyOnDroid:** **out of scope by owner decision.** Both require
external GitLab/Codeberg identities and review; do not submit, create accounts,
or present them as live. The self-hosted F-Droid repo above is the supported
F-Droid-family channel.
- **No GitHub required.** Keep Zapstore, Forgejo, the self-hosted F-Droid repo,
and the static page in sync for each release.
### Future release / distribution checklist
1. Bump `package.json`; update the top of `release-notes.txt`; run
`scripts/release.sh patch --yes --deploy`.
2. Set `FORGEJO_TOKEN` (or `FORGEJO_PASSWORD`) to upload the APK/checksum to the
latest Forgejo release; the helper is `scripts/publish-forgejo.sh`.
3. **Push source/tags to Forgejo.** The local machine's DNS for
`git.skate-spots.org` is intermittent, so the reliable path is: create a git
bundle locally, `scp` it to the relay, clone there, and push to
`http://127.0.0.1:3000/skatespots/SkateSpots.git` with the host admin
credentials. Do not put the password in a committed remote URL.
4. Add the APK to `/home/ubuntu/fdroid/repo/`, update the F-Droid metadata
changelog, run `fdroid update` in `/home/ubuntu/fdroid`, then rsync `repo/`
to `/var/www/fdroid/repo/`.
5. Update `public/download/index.html` with the new version/checksum and deploy
the web page.
6. Verify: latest Forgejo release exposes one `.apk`; static page 200;
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`; Obtanium still resolves the latest release.
7. Keep Forgejo/F-Droid credentials and the F-Droid repo keystore out of git.
Local backups: `~/.config/skatespots/forgejo-admin.txt` and
`~/.config/skatespots/fdroid-repo/` (mode 600).
### Nostr-only distribution (no external identities)
- **Zapstore** is the Nostr-native app store/update channel; keep publishing
release events there.
- **Obtanium** is URL-based, not an account: users add the latest Forgejo
release URL (HTML source if needed) or the self-hosted F-Droid repo.
- **F-Droid main and IzzyOnDroid do not support Nostr identities.** They are
intentionally not pursued. Do not create or request GitLab/Codeberg/email
accounts, and do not claim those stores are live.
- The local Forgejo admin account, the relay, and the F-Droid repo signing key
are self-hosted infrastructure, not third-party identities.
- The F-Droid recipe remains in `distribution/fdroiddata/` only as an unused
technical reference. It was locally test-built at `5ebf741`, but must not be
submitted.
### Deploy ### 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.
@ -2381,6 +2542,10 @@ Rules:
- Flags: `--probe-push` runs a bounded 45s **real** `git push` — signing only happens when there *is* something to push, so this is the only true signer probe (it publishes any unpushed commits; a timeout ⇒ signer down ⇒ open Amber in the foreground, or `ngit account login --interactive`). `--notify` = desktop notification (deduped by an actionable-problem signature). `--quiet` = silent when healthy. Test against a fake state dir with `SKATESPOTS_STATE_DIR=…`. - Flags: `--probe-push` runs a bounded 45s **real** `git push` — signing only happens when there *is* something to push, so this is the only true signer probe (it publishes any unpushed commits; a timeout ⇒ signer down ⇒ open Amber in the foreground, or `ngit account login --interactive`). `--notify` = desktop notification (deduped by an actionable-problem signature). `--quiet` = silent when healthy. Test against a fake state dir with `SKATESPOTS_STATE_DIR=…`.
- ⚠️ `git push --dry-run` does **NOT** exercise signing (prints "Everything up-to-date", never signs) — do not use it as a signer probe. - ⚠️ `git push --dry-run` does **NOT** exercise signing (prints "Everything up-to-date", never signs) — do not use it as a signer probe.
- **Runs automatically every 15 min:** `skatespots-ops-health.timer` → `skatespots-ops-health.service` (`--quiet --notify`). Install/refresh with `scripts/install-ops-health-timer.sh`; logs `journalctl --user -u skatespots-ops-health`. - **Runs automatically every 15 min:** `skatespots-ops-health.timer` → `skatespots-ops-health.service` (`--quiet --notify`). Install/refresh with `scripts/install-ops-health-timer.sh`; logs `journalctl --user -u skatespots-ops-health`.
- **Community bot check is conservative:** a failed SSH probe now reports
“state unknown” and does **not** notify. Only an actual `inactive`/`failed`
systemd state is actionable. This stops transient SSH/DNS hiccups from
producing “community Cordn bot is unreachable” notifications.
- **Web (manual)**: `npm run build` produces the web `dist/` (with `_headers`/`_redirects`); deploy by dragging `dist/` to the Netlify site's Deploys tab. - **Web (manual)**: `npm run build` produces the web `dist/` (with `_headers`/`_redirects`); deploy by dragging `dist/` to the Netlify site's Deploys tab.
- **Zapstore (Android)**, from the repo root: `SIGN_WITH='bunker://...' zsp publish zapstore.yaml` - **Zapstore (Android)**, from the repo root: `SIGN_WITH='bunker://...' zsp publish zapstore.yaml`
- `zsp publish --wizard` is **broken for this app** (the wizard can't parse a nostrhub web `repository` — it supports only GitHub/GitLab/Gitea/F-Droid/local). Always pass `zapstore.yaml`. - `zsp publish --wizard` is **broken for this app** (the wizard can't parse a nostrhub web `repository` — it supports only GitHub/GitLab/Gitea/F-Droid/local). Always pass `zapstore.yaml`.
@ -2594,6 +2759,237 @@ Rules:
with the new device) and added the bot’s 30/hour approval brake plus daily with the new device) and added the bot’s 30/hour approval brake plus daily
30-snapshot local/restic state backups. APK sha256 `51dfe67e…`, versionCode 30-snapshot local/restic state backups. APK sha256 `51dfe67e…`, versionCode
10730. Web forced live at `bde3d2484`. 10730. Web forced live at `bde3d2484`.
- **1.7.31 (2026-10-29):** animated orange-wheel launch splash (roll-in +
spin + sparks; reduced-motion static), native Android splash regenerated
from `resources/splash.png` instead of the stock blue placeholder, Cordn
**Private rooms** in Messages (Community + crews, Open/Set up, local
unread dots), per-room mute, in-room search, coordinator last-checked,
Chat-vs-Private explainer, approve/remove confirmations, and a device-local
Cordn outbox with bounded auto-retry. APK sha256 `d84b57de…`, versionCode
10731. Web forced live (manual `netlify deploy`; the release-script deploy
hit a transient build failure).
- **1.7.32 (2026-10-29):** sponsor-ready profiles — Follow/Unfollow,
Followers/Following counts, Events hosted (`useUserEvents`), a balanced
six-cell stats strip, and a sponsor/collab card with audience stats,
prefilled collab DM and Rider Card link. NIP-05 shows a verifying state;
public stash images use the shared fallback. APK sha256 `31aced78…`,
versionCode 10732. Web forced live (manual `netlify deploy`; the
release-script deploy still hits the Netlify build failure).
- **1.7.33 (2026-10-29):** explicit profile **Open to sponsorships** opt-in
(`sponsorships_open` + `sponsorships_note` in kind-0; only shown when
opted in), plus a Bitcoin grantmaker inquiry form on `/supporters`.
The form opens a private DM to the owner and stores nothing; its funny
disclosure says KYC is for grantees, not for SkateSpots. Followers stat
now has a “relay-reported, not audited” info hint. APK sha256
`fd6ffb8b…`, versionCode 10733. Web forced live (manual
`netlify deploy`; the release-script deploy still hits the Netlify build
failure).
- **1.7.34 (2026-10-29):** follower stat layout fixed (2/3/6 responsive
columns; relay-reported caveat moved to a footnote instead of an edge
tooltip), and the Bitcoin grantmaker section gained explicit ethos and
privacy boundaries (no KYC of SkateSpots, no tracking/CRM, private
NIP-17 DM, open-source-only grants). APK sha256 `a23e8c3d…`, versionCode
10734. Web forced live at `d3d82f7`.
- **1.7.35 (2026-10-29):** replaced the floating mobile “Links” pill over
the bottom nav with a slim always-visible footer strip. APK sha256
`d3cfdc3d…`, versionCode 10735. Web forced live at `5d40a67`.
- **1.7.36 (2026-10-29):** new **Transport / Driver** Lab craft (kind
`35016`, self-tag `skatetransport`) — vehicles, services, capabilities,
capacity and coverage; `/lab?craft=transport` filters; a self-attested
notice (no licence/insurance/operator verification); WoT/vouches/reviews
inherited from the shared Lab detail; stats/impact counts. All 24 locales
100%. APK sha256 `53286e0f…`, versionCode 10736. Web forced live at
`b0f6dd9`.
- **1.7.37 (2026-10-29):** Cordn coordinator client tolerates empty tool
responses: reads `structuredContent` or JSON text, and falls back to
`{requests:[]}`/`{welcomes:[]}`/`{messages:[]}`/`{keyPackages:[]}` instead
of a Zod “expected object, received undefined” error. APK sha256
`6cacedc0…`, versionCode 10737. Web forced live at `b94ac3d`.
- **1.7.38 (2026-10-29):** audit pass — follower query limit 100→500;
Clothing Designers added to `/stats` + Supporters impact; Sasquatch FAB
hidden on `/become`, create/edit and `/supporters` forms; protobufjs
override 7.6.5 (only remaining production advisory is write-only xlsx).
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`.
- **1.7.66 (2026-10-31):** high-value safety copy (no arbitration, escrow),
180-day half-life reputation weighting, and the unit-tested 2-of-3 escrow
policy primitive (not exposed in UI pending arbiter policy). APK sha256
`0ed775ed…`, versionCode 10766. Web live at `cc0545c`.
- **1.7.67 (2026-10-31):** crew-owned Cordn coordinator deployment kit
(`deploy/cordn/`: Compose, SQLite volume, env template, backup/restore,
runbook); persistent Docker command and links from Settings/Crew edit.
No embedded coordinator, no BFT claim. APK sha256 `ac7c9589…`, versionCode
10767. Web live at `c8f471b`.
- **1.7.68 (2026-10-31):** real ContextVM RPC coordinator health probe with
throwaway key, and optional crew coordinator operator field. APK sha256
`d8222789…`, versionCode 10768. Web live at `0fe82c2`.
- **1.7.69 (2026-10-31):** desktop status control is a compact status
emoji/label chip (no large outlined pill or permanent ping). APK sha256
`45add6a3…`, versionCode 10769. Web live at `226d0c3`.
- **1.7.70 (2026-10-31):** Amber inbox decrypt gate (explicit Unlock card),
10-minute expiry for redirect pending ops, and no plaintext/crypto results
left in browser storage. APK sha256 `be521c16…`, versionCode 10770. Web
live at `0859327`.
- **1.7.71 (2026-11-01):** desktop status control is a compact emoji-only
chip with a tooltip; mobile avatar duplicate online dot removed. APK sha256
`5334be69…`, versionCode 10771. Web live at `42ffcb1`.
- **1.7.72 (2026-11-01):** relay events are signature-verified before the
shared merge path; image/avatar requests and the site referrer policy use
no-referrer. APK sha256 `c2169cae…`, versionCode 10772. Web live at
`78741f9`.
- **1.7.73 (2026-11-01):** login dialog now has two adaptive methods:
Amber on Android, NIP-46 QR on desktop, with bunker paste as a secondary
path and no duplicate NIP-46 card. APK sha256 `bcd51861…`, versionCode
10773. Web live at `719a679`.
- **1.7.74 (2026-11-01):** one-command crew coordinator installer
(`deploy/cordn/install.sh`), persistent SQLite + nprofile output; Settings
shows the installer command. Phone host limitation documented. APK sha256
`da0914b9…`, versionCode 10774. Web live at `6ec5a9f`.
- **1.7.75 (2026-11-01):** crew coordinator routing is authoritative (no
silent fallback to SkateSpots); per-coordinator join notifications; device
link errors show retry. APK sha256 `530041f1…`, versionCode 10775. Web
live at `c21d1b0`.
- **1.7.76 (2026-11-01):** PWA 192/512/1024 icons, 512 maskable, 180 Apple
touch icon; signup QR print popup uses 100dvh. APK sha256 `175ab9d1…`,
versionCode 10776. Web live at `2e904c2`.
- **1.7.77 (2026-11-01):** wallet flow simplified: no brand directory,
hidden WebLN when absent, one connect CTA, generic help link. APK sha256
`47eb0a54…`, versionCode 10777. Web live at `b71ff5b`.
- **1.7.78 (2026-11-01):** native Wallet page/manager leads with built-in
Cashu wallet; slightly larger mobile nav labels. APK sha256 `661ef822…`,
versionCode 10778. Web live at `66928b2`.
- **1.7.79 (2026-11-01):** nsec logins are sessionStorage-only and stripped
from persistent localStorage; direct non-root paths rewrite into
HashRouter before mount. APK sha256 `8fdf506b…`, versionCode 10779. Web
live at `d0a593e`.
### Relay archive (dataset backup) ### 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):**
@ -2654,6 +3050,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
@ -2897,6 +3298,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
@ -2925,7 +3331,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
@ -2936,6 +3344,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 =
@ -3005,7 +3431,7 @@ The community relay moved off the hosted relay.tools instance
- `git push` may print a benign `cannot lock ref ... incorrect old value provided` retry warning; verify with `git fetch origin && git rev-list --left-right --count origin/main...HEAD`. - `git push` may print a benign `cannot lock ref ... incorrect old value provided` retry warning; verify with `git fetch origin && git rev-list --left-right --count origin/main...HEAD`.
### Secrets — never commit or paste ### Secrets — never commit or paste
`keystore.properties`, `*.jks`, `.env*`, and `android/` are gitignored. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager. Signing material (`keystore.properties`, `*.jks`) lives **outside the repo** at `~/.config/skatespots/android/`; `*.jks`, `.env*`, and `android/` remain gitignored as a safety net. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager. Do not bind the Vite dev server to the LAN; use `adb reverse` for device testing.
## Project Structure ## Project Structure
@ -3104,6 +3530,16 @@ These components follow a consistent pattern using React's `forwardRef` and use
The project includes a **`skills/`** directory containing specialized implementation guides for specific features. Each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. **Always reference the appropriate SKILL.md file when implementing related functionality.** The project includes a **`skills/`** directory containing specialized implementation guides for specific features. Each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. **Always reference the appropriate SKILL.md file when implementing related functionality.**
### Deferred: NIP-05 as a wallet address
**Do not set `lud16` to `name@skate-spots.org` until an LNURL-pay service exists.**
NIP-05 is a Nostr identity record; a Lightning address is a separate
`/.well-known/lnurlp/<name>` service. A Minibits-style wallet address needs the
NIP-05 server **and** an LNURL/mint/claim service (plus rate limits, backups,
privacy copy and a clear mint-trust model). Optional future paths:
self-hosted LNURL + Cashu mint, or a deliberate npub.cash/Minibits-style
integration. Until then keep NIP-05 as identity and use NWC/built-in Cashu;
never overwrite a user's custom `lud16`.
### Wallet Skills ### Wallet Skills
- **`skills/bitcoin-wallet/SKILL.md`**: Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns. Read when building Bitcoin wallet functionality, adding on-chain transaction support, implementing UTXO management, or integrating Bitcoin with Nostr-based applications. - **`skills/bitcoin-wallet/SKILL.md`**: Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns. Read when building Bitcoin wallet functionality, adding on-chain transaction support, implementing UTXO management, or integrating Bitcoin with Nostr-based applications.

View file

@ -77,11 +77,26 @@ token in a public event is spendable by anyone who sees it.
promptly, and lock windows should be generous. promptly, and lock windows should be generous.
- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too. - **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too.
## Optional 2-of-3 primitive
`escrowP2PKOptions` now accepts an optional `arbiterPubkey` and can express the
NUT-11 policy used by a future 2-of-3 escrow:
- normal path: seller + arbiter, `n_sigs=2`;
- refund path: buyer + arbiter, `n_sigs_refund=2`;
- no arbiter: the current 1-of-1 seller/buyer policy.
The primitive is unit-tested, but **no UI or default flow uses it yet**. There is
no arbiter policy, bond, dispute process or appeal path, so exposing it would add
friction and false confidence. Do not enable it for real money until those are
defined and the flow is verified on-device.
## Status ## Status
- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation, - ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation,
`escrowP2PKOptions`, offline token inspection/`parseEscrowToken`, `escrowP2PKOptions` (1-of-1, optional 2-of-3), offline token
locktime/refund policy, and the injectable `createEscrowToken` mint flow. inspection/`parseEscrowToken`, locktime/refund policy, and the injectable
`createEscrowToken` mint flow.
- 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test - 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test
checklist** below before treating as production. Do not use real money until checklist** below before treating as production. Do not use real money until
every box passes. every box passes.

38
NIP.md
View file

@ -14,6 +14,8 @@ other clients, and preserved on edit:
| `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. | | `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. |
| `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. | | `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. |
| `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). | | `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). |
| `sponsorships_open` | boolean | Explicit opt-in: the skater is open to sponsorship/collab conversations. Absence means **not** opted in; never infer this from activity. |
| `sponsorships_note` | string | Optional short note shown on the sponsor/collab card when `sponsorships_open` is true. |
| `cordn` | string | The **Cordn coordinator** others use to reach you — private, end-to-end encrypted messaging. Holds a coordinator **pointer**: either an `nprofile` (coordinator pubkey + relay hints), Cordn's canonical `https://cordn.net/chat/coordinators?c=<nprofile>` add-link, or an `http(s)` endpoint URL. This is the discovery field: clients SHOULD offer a one-tap **add** when it parses as a pointer, and otherwise open it as a plain link. See *Private Crew Chat (Cordn / MLS)*. | | `cordn` | string | The **Cordn coordinator** others use to reach you — private, end-to-end encrypted messaging. Holds a coordinator **pointer**: either an `nprofile` (coordinator pubkey + relay hints), Cordn's canonical `https://cordn.net/chat/coordinators?c=<nprofile>` add-link, or an `http(s)` endpoint URL. This is the discovery field: clients SHOULD offer a one-tap **add** when it parses as a pointer, and otherwise open it as a plain link. See *Private Crew Chat (Cordn / MLS)*. |
| `cashu_escrow` | string | Cashu escrow pubkey (33-byte compressed secp256k1 hex, `02…`) used as the P2PK lock target for marketplace escrow. Derived from the Cashu wallet keyring. See `ESCROW.md`. | | `cashu_escrow` | string | Cashu escrow pubkey (33-byte compressed secp256k1 hex, `02…`) used as the P2PK lock target for marketplace escrow. Derived from the Cashu wallet keyring. See `ESCROW.md`. |
| `cashu_mint` | string | Preferred Cashu mint URL for marketplace escrow (`https://…`). Buyers are nudged to lock funds at this mint, since a mint the seller already uses is one they can redeem from. Optional; see `ESCROW.md`. | | `cashu_mint` | string | Preferred Cashu mint URL for marketplace escrow (`https://…`). Buyers are nudged to lock funds at this mint, since a mint the seller already uses is one they can redeem from. Optional; see `ESCROW.md`. |
@ -29,6 +31,8 @@ Example:
"lud16": "sk8rboi@getalby.com", "lud16": "sk8rboi@getalby.com",
"bitcoin_silent_payment": "sp1qq...long...", "bitcoin_silent_payment": "sp1qq...long...",
"sponsors": "Independent Trucks, Local Skate Co.", "sponsors": "Independent Trucks, Local Skate Co.",
"sponsorships_open": true,
"sponsorships_note": "Open to deck, shoe, travel and event partners.",
"cordn": "https://cordn.net/u/abc123", "cordn": "https://cordn.net/u/abc123",
"noffer": "noffer1..." "noffer": "noffer1..."
} }
@ -1844,6 +1848,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35013 | Event Organizer / Host | `skateorganizer` | | 35013 | Event Organizer / Host | `skateorganizer` |
| 35014 | Barber / Hairstylist | `skatebarber` | | 35014 | Barber / Hairstylist | `skatebarber` |
| 35015 | Clothing Designer | `skateclothing` | | 35015 | Clothing Designer | `skateclothing` |
| 35016 | Transport / Driver | `skatetransport` |
> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below). > Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below).
@ -1888,7 +1893,7 @@ below), `p` (collective member pubkeys — see Collective Membership below),
#### Shared enrichment (all Lab crafts) #### Shared enrichment (all Lab crafts)
Every Lab provider kind (35001–35014) may also carry the same craft-agnostic Every Lab provider kind (35001–35016) may also carry the same craft-agnostic
enrichment tags — how they work, how to pay them, and their media. They are enrichment tags — how they work, how to pay them, and their media. They are
repeatable, **not relay-indexed** (clients filter them client-side), and parsed repeatable, **not relay-indexed** (clients filter them client-side), and parsed
by **`src/lib/labExtras.ts`** (`parseLabExtras`), which the shared profile detail by **`src/lib/labExtras.ts`** (`parseLabExtras`), which the shared profile detail
@ -2417,12 +2422,39 @@ Barber-specific optional tags:
A shop or collective can be a `collective` with member `p` tags, and may link to A shop or collective can be a `collective` with member `p` tags, and may link to
its skate crew via an `a` tag (`35006:pubkey:d`). its skate crew via an `a` tag (`35006:pubkey:d`).
### Transport / Driver (kind 35016)
Skaters who can move people, gear or cargo — cars, taxis, vans, motorcycles,
helicopters, planes and boats. **Self-attested:** the app does not verify
licences or insurance; trust comes from WoT, vouches, reviews and reports.
Uses the common base schema.
Categories encoded as `t` tags:
- **Vehicles**: `car`, `taxi`, `van`, `moto`, `helicopter`, `plane`, `boat`, `other`
- **Services**: `people`, `goods`, `gear`, `airport`, `venue`, `tours`, `logistics`, `other`
- **Capabilities**: `cargo`, `bike_board`, `wheelchair`, `pets`, `border`, `night`, `covered`, `group`
Transport-specific optional tags:
| Tag | Description |
|-----|-------------|
| `capacity` | Passenger / board capacity note. |
| `cargo` | Cargo space / weight / volume note. |
| `coverage` | Routes, regions or cross-border coverage. |
| `availability` | Freeform availability. |
| `base` | Base area / airport. |
| `accessibility` | Ramp, wheelchair space, assistance, etc. |
A transport company can be a `collective` with member `p` tags, and may link to
its skate crew via an `a` tag (`35006:pubkey:d`).
### Querying Lab Profiles ### Querying Lab Profiles
```json ```json
{ {
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014], "kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014, 35015, 35016],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber"], "#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber", "skateclothing", "skatetransport"],
"limit": 100 "limit": 100
} }
``` ```

View file

@ -156,16 +156,18 @@ Or use Android Studio: right-click `app` → New → Image Asset → import `pub
## Step 5 — Generate a signing keystore (one-time) ## Step 5 — Generate a signing keystore (one-time)
```bash ```bash
install -d -m 700 ~/.config/skatespots/android
keytool -genkey -v \ keytool -genkey -v \
-keystore skatespots-release.jks \ -keystore ~/.config/skatespots/android/skatespots-release.jks \
-alias skatespots \ -alias skatespots \
-keyalg RSA \ -keyalg RSA \
-keysize 2048 \ -keysize 2048 \
-validity 10000 \ -validity 10000 \
-dname "CN=SkateSpots, OU=SkateSpots, O=SkateSpots, L=Bangkok, S=Bangkok, C=TH" -dname "CN=SkateSpots, OU=SkateSpots, O=SkateSpots, L=Bangkok, S=Bangkok, C=TH"
chmod 600 ~/.config/skatespots/android/skatespots-release.jks
``` ```
**Save the keystore file and passwords somewhere safe — you need them for every future release.** Put the passwords in `~/.config/skatespots/android/keystore.properties` (mode 600), not in the repo. **Save the keystore file and passwords somewhere safe — you need them for every future release.**
--- ---
@ -175,18 +177,7 @@ keytool -genkey -v \
npm run android:build npm run android:build
``` ```
Or manually with signing: `npm run android:build` injects the external signing config automatically. Do **not** pass signing passwords as `-Pandroid.injected...` command-line properties; that exposes them in shell history and process listings.
```bash
npm run build
npx cap sync android
cd android
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=../skatespots-release.jks \
-Pandroid.injected.signing.store.password=YOUR_STORE_PASSWORD \
-Pandroid.injected.signing.key.alias=skatespots \
-Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD
```
APK output: `android/app/build/outputs/apk/release/app-release.apk` APK output: `android/app/build/outputs/apk/release/app-release.apk`

19
deploy/cordn/.env.example Normal file
View file

@ -0,0 +1,19 @@
# Copy this file to .env, then generate a key:
# openssl rand -hex 32
# Keep .env mode 600 and back it up with the coordinator data.
CORDN_SERVER_PRIVATE_KEY=
# ContextVM relays the coordinator connects to. Add a second one if you want
# more reachability; the coordinator does not need an inbound port.
CORDN_RELAY_URLS=wss://relay.contextvm.org,wss://relay2.contextvm.org
# Publicly announce the coordinator on its relays.
CORDN_ANNOUNCED=true
CORDN_SERVER_NAME=my-crew-coordinator
# Per-identity abuse protection. Raise for larger crews; keep it bounded.
CORDN_RATE_LIMIT_ENABLED=true
CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
CORDN_RATE_LIMIT_BURST=80
CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1

153
deploy/cordn/README.md Normal file
View file

@ -0,0 +1,153 @@
# Crew-owned Cordn coordinator
This kit runs a **Cordn MLS delivery-service coordinator** that a crew owns. It
is transport only: the coordinator sees ciphertext and routing metadata (who,
when, which group), never message plaintext. It can withhold or delay messages;
it cannot forge members' messages.
This is a **server** kit. SkateSpots remains a Cordn *client*; it does not embed a
coordinator in the APK.
## What you need
- A small Linux server or always-on home machine with Docker + Compose.
- Outbound HTTPS/WSS access. **No inbound port is required** — the coordinator
connects out to its ContextVM relay(s).
- A domain is **not** required for the coordinator itself.
- A crew willing to trust the operator with availability and metadata.
## 1. Install
### Easiest: one command
```bash
curl -fsSL https://git.skate-spots.org/skatespots/SkateSpots/raw/branch/main/deploy/cordn/install.sh | bash
```
It checks Docker, generates the coordinator key, writes a private `.env`, starts
SQLite persistence, waits for the coordinator to announce, and prints the
`nprofile`/add link.
No inbound port, domain or TLS setup is required. The coordinator connects
outbound to the ContextVM relays in `CORDN_RELAY_URLS`.
### Manual: from this directory
```bash
cp .env.example .env
chmod 600 .env
openssl rand -hex 32
```
Put the generated value into `CORDN_SERVER_PRIVATE_KEY`. Set
`CORDN_RELAY_URLS` to the relay(s) you want; the default is ContextVM’s public
relay. Set a useful `CORDN_SERVER_NAME`.
```bash
docker compose up -d
docker compose logs -f cordn
```
The server prints its coordinator public identity/add link. It may show an
`nprofile` or a `cordn.net/chat/coordinators?c=…` link.
If your build does not print an add link, derive the public key from the private
key using the Cordn CLI or `nostr-tools`, then encode an `nprofile` with the same
relay hints. Keep the private key on the server.
### Can I run it on a phone?
Not with this Docker kit. A phone can **administer** a coordinator, and Cordn's
own phone app is the hand-off path, but an always-on coordinator needs a machine
that stays awake and reachable. Android may suspend a foreground coordinator;
do not promise crew members that a phone is a reliable coordinator host.
For a crew that wants ownership without a VPS, an old Raspberry Pi or a
small always-on home server is the simplest reliable option. The coordinator
still needs no inbound port.
## 2. Adopt it in SkateSpots
Either:
- Settings → **Coordinator** → paste the `nprofile`/add-link → **Add**; or
- Crew edit → **Coordinator** → paste it and save; or
- scan the coordinator QR.
Use exactly the relay hints from the server. A mismatch produces an honest
“Not seen” health state even when the server is actually running.
The coordinator should show **Online** (or “Stale” if its heartbeat is old).
“Offline” means presence was not found, not necessarily that the service is
broken.
## 3. What the crew should know
- The coordinator can **censor or drop messages**.
- It cannot **read** MLS plaintext.
- It can see **who, when and which group**.
- It can disappear; local messages remain on members' devices, but delivery
pauses until the coordinator returns or the crew switches.
- The operator should **not** also become a crew admin automatically.
- A loss of the database is a loss of delivery history; back it up.
## 4. Backup
```bash
./backup.sh
```
Backups land in `./backups/` as a compressed copy of the coordinator volume.
Store them off the server, encrypted.
## 5. Restore
Stop the coordinator, restore the archive into the `cordn-data` volume, start it
again:
```bash
docker compose stop cordn
./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
docker compose start cordn
```
## 6. Upgrade
```bash
docker compose pull
docker compose up -d
```
The image is pinned only by the `latest` tag. If you need reproducible upgrades,
pin a digest and record it in your crew runbook.
## 7. Move to a new coordinator
MLS delivery state is tied to the coordinator’s database and identity. Before
switching:
1. back up the current coordinator;
2. export/retain the crew’s local chat state on members’ devices;
3. start the new coordinator;
4. paste its `nprofile` into the crew record;
5. have members reopen the private chat and re-establish delivery;
6. update the crew operator/notice.
Do not claim the switch is seamless unless the old and new coordinator state
have actually been migrated.
## Security notes
- Never commit `.env`; it contains the coordinator signing key.
- The coordinator key is **not** a crew admin key and must not be reused as one.
- Run it behind a trusted host/network; outbound relay traffic is metadata.
- Keep Docker and the image current.
- Do not expose an OpenCode or coordinator admin port publicly.
- This is **not** a BFT or multi-coordinator system. One coordinator is a
delivery service under the crew's social trust.
## Files
- `docker-compose.yml` — persistent SQLite deployment.
- `.env.example` — required environment.
- `backup.sh` / `restore.sh` — volume snapshots.

14
deploy/cordn/backup.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/sh
# Snapshot the Cordn coordinator data volume. Run from this directory.
set -eu
cd "$(dirname "$0")"
mkdir -p backups
stamp="$(date +%Y%m%d-%H%M%S)"
out="cordn-$stamp.tgz"
docker run --rm \
-v cordn_cordn-data:/data:ro \
-v "$PWD/backups":/backup \
alpine:3.20 \
sh -c "tar czf /backup/$out -C /data ."
echo "Backup: $PWD/backups/$out"
echo "Store it off this server and keep it encrypted."

View file

@ -0,0 +1,19 @@
name: cordn
services:
cordn:
image: ghcr.io/cordn-msg/cordn:latest
container_name: cordn
restart: unless-stopped
env_file:
- .env
environment:
CORDN_STORAGE_BACKEND: sqlite
CORDN_SQLITE_PATH: /data/cordn.sqlite
volumes:
- cordn-data:/data
# No inbound port is required: the coordinator connects *out* to the
# ContextVM relay(s) configured in CORDN_RELAY_URLS.
volumes:
cordn-data:

77
deploy/cordn/install.sh Executable file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# One-command crew coordinator installer.
#
# curl -fsSL https://git.skate-spots.org/skatespots/SkateSpots/raw/branch/main/deploy/cordn/install.sh | bash
#
# Creates a persistent Cordn coordinator with SQLite storage, starts it, and
# prints the nprofile/add link when the server announces itself. The coordinator
# connects OUT to ContextVM relays; it needs no inbound port or TLS domain.
set -euo pipefail
cd "$(dirname "$0")" 2>/dev/null || true
umask 077
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
need docker
need openssl
docker compose version >/dev/null 2>&1 || { echo "Docker Compose v2 is required (docker compose)." >&2; exit 1; }
if [ ! -f docker-compose.yml ]; then
cat > docker-compose.yml <<'YAML'
name: cordn
services:
cordn:
image: ghcr.io/cordn-msg/cordn:latest
container_name: cordn
restart: unless-stopped
env_file:
- .env
environment:
CORDN_STORAGE_BACKEND: sqlite
CORDN_SQLITE_PATH: /data/cordn.sqlite
volumes:
- cordn-data:/data
volumes:
cordn-data:
YAML
fi
if [ ! -f .env ]; then
NAME="${CORDN_SERVER_NAME:-skatespots-crew}"
RELAYS="${CORDN_RELAY_URLS:-wss://relay.contextvm.org,wss://relay2.contextvm.org}"
KEY="$(openssl rand -hex 32)"
cat > .env <<EOF
CORDN_SERVER_PRIVATE_KEY=$KEY
CORDN_RELAY_URLS=$RELAYS
CORDN_ANNOUNCED=true
CORDN_SERVER_NAME=$NAME
CORDN_STORAGE_BACKEND=sqlite
CORDN_SQLITE_PATH=/data/cordn.sqlite
CORDN_RATE_LIMIT_ENABLED=true
CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
CORDN_RATE_LIMIT_BURST=80
CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1
EOF
chmod 600 .env
echo "Generated .env (keep it private; it contains the coordinator key)."
fi
docker compose up -d
echo "Waiting for the coordinator to announce itself…"
for _ in $(seq 1 30); do
logs="$(docker compose logs --no-color 2>&1 || true)"
if printf '%s\n' "$logs" | grep -q 'nprofile'; then
printf '\n%s\n' "$logs" | grep -A12 -B2 'CORDN COORDINATOR'
echo
echo "Back up .env and the cordn-data volume before inviting the crew."
echo "Adopt it in SkateSpots: Settings → Coordinator, or Crew edit → Coordinator."
exit 0
fi
sleep 2
done
echo "The container started but no announcement appeared within 60s." >&2
echo "Inspect: docker compose logs --no-color" >&2
exit 1

17
deploy/cordn/restore.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
# Restore a Cordn coordinator backup into the cordn-data volume.
# Usage: ./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
set -eu
cd "$(dirname "$0")"
[ "$#" -eq 1 ] || { echo "usage: $0 backups/cordn-YYYYMMDD-HHMMSS.tgz" >&2; exit 1; }
archive="$1"
[ -f "$archive" ] || { echo "no such backup: $archive" >&2; exit 1; }
printf 'This replaces the current coordinator volume. Type "restore" to continue: '
read -r answer
[ "$answer" = restore ] || { echo "aborted" >&2; exit 1; }
docker run --rm \
-v cordn_cordn-data:/data \
-v "$(cd "$(dirname "$archive")" && pwd)":/backup:ro \
alpine:3.20 \
sh -c "rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true; tar xzf /backup/$(basename "$archive") -C /data"
echo "Restored $(basename "$archive")."

97
distribution/README.md Normal file
View file

@ -0,0 +1,97 @@
# Distribution without GitHub
SkateSpots is published from a self-hosted **Forgejo** instance. No GitHub
account is required.
## Current release source
- Forge: <https://git.skate-spots.org/skatespots/SkateSpots>
- Latest release: <https://git.skate-spots.org/skatespots/SkateSpots/releases/latest>
- Signed APK (1.7.83):
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.83/SkateSpots-1.7.83.apk>
- SHA-256:
`1fe70588adea237045b117ccdf013811b87a8368caf6b1148a7ab7da0a6250c3`
The APK is signed with the same key as the Zapstore build. Installing an
F-Droid-signed build over it would require uninstalling SkateSpots and losing
the local key/settings. Obtanium and direct APK updates keep the install.
## Obtanium
Obtanium cannot use a Zapstore URL as a source. Use the self-hosted Forgejo
release page instead:
1. Open Obtanium → **Add App**.
2. Paste:
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`
3. If Obtanium does not recognise the self-hosted host, select **Override
Source → HTML**.
4. Set version extraction if desired:
`SkateSpots-([0-9.]+)\.apk`, match group `1`.
The release page contains exactly one `.apk`, so Obtanium can follow the latest
release without a custom store listing.
## F-Droid family
### Self-hosted F-Droid repo (live now)
A signed third-party F-Droid repository is published here:
- Repo: <https://fdroid.skate-spots.org/repo>
- App ID: `org.skatespots.app`
- Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`
Users can add this URL in F-Droid, or add it in Obtanium as an **F-Droid Third
Party Repo**. This is not F-Droid main: it is a self-hosted, signed repository
of the developer-signed APK. It keeps the existing install and signing key.
The repository metadata and APK are served from the relay host. The repo index
is signed with a repository key stored on that host; the APK itself keeps the
SkateSpots developer signature.
### No external store identities
**Owner decision:** distribution stays self-hosted and Nostr-based. No GitLab,
Codeberg, GitHub, email or F-Droid-main accounts are created or requested.
F-Droid main and IzzyOnDroid both require external identities and review, so
they are intentionally out of scope. The self-hosted repo above is the supported
F-Droid-family channel.
`distribution/fdroiddata/` and `fastlane/` are kept as an unused technical
reference only. A fresh local clone at `5ebf741` built the unsigned APK through
the recipe's `npm ci → android:init → android:sync → assembleRelease` path, but
the recipe must not be submitted.
## Release checklist
1. `npm test`
2. `scripts/release.sh patch --yes --deploy` (Zapstore + web)
3. Push the release tag and `main` to Forgejo.
4. Upload the APK through the helper:
`FORGEJO_TOKEN=... scripts/publish-forgejo.sh <version>`
(or set `FORGEJO_TOKEN` before `release.sh`; the APK upload is then automatic)
5. Copy the signed APK into the F-Droid repo on the relay host, add a
`metadata/org.skatespots.app.yml` changelog entry, run `fdroid update`, and
sync `repo/` to `/var/www/fdroid/repo/`:
```bash
cd /home/ubuntu/fdroid
cp /path/to/SkateSpots-<version>.apk repo/
. /home/ubuntu/fdroid-venv/bin/activate
fdroid update
sudo rsync -a --delete repo/ /var/www/fdroid/repo/
sudo chown -R caddy:caddy /var/www/fdroid
```
6. Verify the public latest-release page contains the new `.apk`, and that
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`.
7. Update the static download page if the displayed version/checksum changes:
`public/download/index.html`.
The Forgejo instance runs on the same host as the relay. It exposes public
read access and HTTPS through Caddy; registration is disabled and administrative
credentials are kept on the host, not in this repository.

View 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
View 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.**

View 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.

View 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.

View 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.

View 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.

View 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.

View file

@ -0,0 +1 @@
High-value listings now show the safety note including the no-arbitration reminder; reputation uses a 180-day half-life and recent 90-day count; the 2-of-3 escrow policy primitive is unit-tested but not exposed until an arbiter policy exists.

View file

@ -0,0 +1 @@
Added a production crew-owned Cordn coordinator kit: Docker Compose with SQLite persistence, an env template, backup/restore scripts, and a runbook. The coordinator needs only outbound relay access; Settings and crew edit link to the kit. The app still does not embed a coordinator.

View file

@ -0,0 +1 @@
The desktop header status control is now a compact chip showing your current status emoji and label, or a neutral Status affordance when none is set. No permanent ping or large outlined pill.

View file

@ -0,0 +1 @@
Amber users now unlock encrypted conversations explicitly instead of triggering a decrypt storm on Messages. Amber redirect requests expire after 10 minutes, and plaintext/crypto results are no longer persisted in browser storage.

View file

@ -0,0 +1 @@
The desktop status control is now a compact emoji-only round chip with a tooltip, so it cannot collide with profile controls. Removed the duplicate online dot from the mobile avatar.

View file

@ -0,0 +1 @@
Relay events are now verified before the shared content merge, and images/avatars use a no-referrer policy to reduce third-party metadata leaks.

View file

@ -0,0 +1 @@
The login dialog now shows two adaptive methods: Amber on Android, or NIP-46 QR pairing on desktop. Bunker paste is a secondary path, and the duplicate NIP-46 card was removed.

View file

@ -0,0 +1 @@
Added a one-command crew coordinator installer that generates the key, starts persistent SQLite storage, and prints the coordinator link. Settings now shows the installer command; no inbound port is required.

View file

@ -0,0 +1 @@
Crew-owned coordinators are now authoritative for their crews, join-request notifications poll each coordinator independently, and device-link failures show an inline retry instead of a stuck loading dialog.

View file

@ -0,0 +1 @@
Added proper 192px and 512px PWA icons plus a 512px maskable icon and a 180px Apple touch icon. The signup recovery-code print popup now respects the dynamic mobile viewport.

View file

@ -0,0 +1 @@
Simplified wallet setup: removed the brand directory and duplicate Add Wallet action, hide WebLN unless detected, and added one neutral connection path.

View file

@ -0,0 +1 @@
The native Wallet page and wallet manager now lead with the built-in SkateSpots ecash wallet, with an existing Lightning wallet as the secondary path. Bottom navigation labels are slightly larger.

View file

@ -0,0 +1 @@
Raw nsec logins are now session-only and removed from persistent browser storage. Direct links to spots, crews, events and listings now enter the app router correctly, including cold native App Links.

View file

@ -0,0 +1 @@
Marketplace UX overhaul: URL-synced filters, mobile filter/action sheets, real link cards, normalized price sorting, draft autosave, live preview and photo cover controls. Show More adds listings beyond the first 200.

View file

@ -0,0 +1 @@
Grantmaker inquiry: shareable grants tab, progressive form, review/copy fallback, website validation and clearer funding priorities. Community invite uses the canonical /community link; retired Nostr relay removed from active configs.

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1 @@
Local-first skate map, crews, events and marketplace.

View file

@ -0,0 +1 @@
SkateSpots

View file

@ -1,9 +1,10 @@
<!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" />
<title>SkateSpots - Decentralized Skate Map &amp; Community</title> <meta name="referrer" content="strict-origin-when-cross-origin" />
<title>SkateSpots — Open-sourced Skate Community App</title>
<!-- <!--
Content-Security-Policy (defense-in-depth) — shipped in the HTML so it Content-Security-Policy (defense-in-depth) — shipped in the HTML so it
@ -44,11 +45,11 @@
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<!-- App Icons --> <!-- App Icons -->
<link rel="icon" type="image/png" href="/images/icon-1024.png"> <link rel="icon" type="image/png" href="/images/brand-192.png">
<link rel="apple-touch-icon" href="/images/icon-1024.png"> <link rel="apple-touch-icon" href="/images/brand-180.png">
<link rel="apple-touch-icon" sizes="152x152" href="/images/icon-1024.png"> <link rel="apple-touch-icon" sizes="180x180" href="/images/brand-180.png">
<link rel="apple-touch-icon" sizes="180x180" href="/images/icon-1024.png"> <link rel="apple-touch-icon" sizes="180x180" href="/images/brand-180.png">
<link rel="apple-touch-startup-image" href="/images/icon-1024.png"> <link rel="apple-touch-startup-image" href="/images/brand-1024.png">
<!-- Theme Color --> <!-- Theme Color -->
<meta name="theme-color" content="#ff0040"> <meta name="theme-color" content="#ff0040">
@ -57,21 +58,27 @@
<meta name="apple-mobile-web-app-title" content="SkateSpots"> <meta name="apple-mobile-web-app-title" content="SkateSpots">
<!-- SEO Meta Tags --> <!-- SEO Meta Tags -->
<meta name="description" content="Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr. Free, decentralized, censorship-resistant."> <meta name="description" content="An open-sourced skate community app for map, crews, events, gear and more.">
<meta name="keywords" content="skateboarding, skate spots, skate parks, nostr, decentralized, bitcoin, lightning, zaps"> <meta name="keywords" content="skateboarding, skate spots, skate parks, nostr, open source, bitcoin, lightning, zaps">
<!-- Open Graph --> <!-- Open Graph -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="SkateSpots - Decentralized Skate Map & Community"> <meta property="og:site_name" content="SkateSpots">
<meta property="og:description" content="Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr."> <meta property="og:title" content="SkateSpots — Open-sourced Skate Community App">
<meta property="og:image" content="https://skate-spots.org/images/icon-1024.png"> <meta property="og:description" content="Map, crews, events, gear and more — an open-sourced skate community app.">
<meta property="og:image" content="https://skate-spots.org/images/og-card-v2.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="SkateSpots — open-sourced skate community app for map, crews, events and gear">
<meta property="og:url" content="https://skate-spots.org"> <meta property="og:url" content="https://skate-spots.org">
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SkateSpots - Decentralized Skate Map"> <meta name="twitter:title" content="SkateSpots — Open-sourced Skate Community App">
<meta name="twitter:description" content="Discover skate spots, find coaches, join events on Nostr."> <meta name="twitter:description" content="Map, crews, events and gear for the skate community.">
<meta name="twitter:image" content="https://skate-spots.org/images/icon-1024.png"> <meta name="twitter:image" content="https://skate-spots.org/images/og-card-v2.png">
<meta name="twitter:image:alt" content="SkateSpots — open-sourced skate community app for map, crews, events and gear">
<!-- Leaflet CSS is self-hosted (imported in main.tsx) — no unpkg/CDN. --> <!-- Leaflet CSS is self-hosted (imported in main.tsx) — no unpkg/CDN. -->
<!-- Warm up the connections used on almost every visit: our relay + media. --> <!-- Warm up the connections used on almost every visit: our relay + media. -->
@ -80,14 +87,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>

122
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.31", "version": "1.7.83",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.31", "version": "1.7.83",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0", "@bitcoinerlab/secp256k1": "^1.2.0",
@ -89,6 +89,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"ecpair": "^3.0.0", "ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"fflate": "^0.8.3",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
@ -120,7 +121,6 @@
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"vaul": "^0.9.3", "vaul": "^0.9.3",
"websocket-ts": "^2.2.1", "websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^4.6.7" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
@ -2946,12 +2946,6 @@
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": { "node_modules/@protobufjs/path": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
@ -6012,15 +6006,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -7054,19 +7039,6 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
@ -7213,15 +7185,6 @@
"typescript": "^5" "typescript": "^5"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -7335,18 +7298,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -8621,15 +8572,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -10926,9 +10868,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.6.3", "version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==", "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@ -10938,7 +10880,6 @@
"@protobufjs/eventemitter": "^1.1.1", "@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1", "@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2", "@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2", "@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0", "@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1", "@protobufjs/utf8": "^1.1.1",
@ -12273,18 +12214,6 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": { "node_modules/stackblur-canvas": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
@ -14134,24 +14063,6 @@
"bs58check": "^4.0.0" "bs58check": "^4.0.0"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -14253,27 +14164,6 @@
"node": ">=14.17.0" "node": ">=14.17.0"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.7.31", "version": "1.7.83",
"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"
@ -111,6 +111,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"ecpair": "^3.0.0", "ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"fflate": "^0.8.3",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
@ -142,7 +143,6 @@
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"vaul": "^0.9.3", "vaul": "^0.9.3",
"websocket-ts": "^2.2.1", "websocket-ts": "^2.2.1",
"xlsx": "^0.18.5",
"zod": "^4.6.7" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
@ -184,7 +184,7 @@
}, },
"overrides": { "overrides": {
"@tanstack/query-core": "$@tanstack/react-query", "@tanstack/query-core": "$@tanstack/react-query",
"protobufjs": "7.6.3", "protobufjs": "7.6.5",
"sharp": "0.35.5" "sharp": "0.35.5"
} }
} }

View file

@ -8,6 +8,71 @@
*/ */
(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.
// 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
// https://localhost; clear its app-shell registrations before the bundle runs.
try {
var isNativeShell = location.protocol === 'https:' && location.hostname === 'localhost';
if (isNativeShell && 'serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function (regs) {
regs.forEach(function (reg) { try { reg.unregister(); } catch (e) {} });
}).catch(function () {});
if (window.caches) {
window.caches.keys().then(function (keys) {
keys.filter(function (key) { return key.indexOf('skate-spots-shell-') === 0; })
.forEach(function (key) { window.caches.delete(key).catch(function () {}); });
}).catch(function () {});
}
}
} catch (e) { /* best effort */ }
function paint(kind, err) { function paint(kind, err) {
if (shown) return; if (shown) return;
shown = true; shown = true;

127
public/download/index.html Normal file
View file

@ -0,0 +1,127 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>SkateSpots APK</title>
<meta
name="description"
content="Download the signed SkateSpots Android APK. No account, no store, no tracking."
/>
<style>
:root {
color-scheme: dark;
--bg: #090b0c;
--panel: #121719;
--line: #273033;
--text: #f2f5f4;
--muted: #9aa7a5;
--teal: #56e0c0;
--amber: #f5b83d;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 20% 0%, rgba(86, 224, 192, 0.12), transparent 42%),
var(--bg);
color: var(--text);
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
main {
width: min(100%, 560px);
padding: 28px;
border: 1px solid var(--line);
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
}
.eyebrow {
margin: 0 0 10px;
color: var(--teal);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(28px, 7vw, 38px); line-height: 1.02; }
p { margin: 14px 0 0; color: var(--muted); }
a.download {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 24px;
padding: 16px 18px;
border-radius: 12px;
background: var(--teal);
color: #06201b;
font-weight: 800;
text-decoration: none;
}
a.download:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
a.download span:last-child { font-size: 20px; }
.meta {
display: grid;
gap: 8px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
code {
overflow-wrap: anywhere;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.links { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 16px; }
.links a { color: var(--amber); font-weight: 700; text-decoration: none; }
.links a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main>
<p class="eyebrow">SkateSpots · Android</p>
<h1>Get the signed APK.</h1>
<p>
Local-first skating map, crews, events and marketplace. No store account
required. This is the same signing key as the Zapstore and Obtanium
releases, so it updates over an existing install.
</p>
<a
class="download"
href="https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.83/SkateSpots-1.7.83.apk"
rel="noopener"
>
<span>Download SkateSpots-1.7.83.apk · 60 MB</span>
<span aria-hidden="true">↓</span>
</a>
<div class="meta">
<span>Requires Android 7.0 or newer.</span>
<span>SHA-256: <code>7c4f8e7a50428c97caba41b311df29a0e619f88a0ef00c686a021fbdc8f75ec0</code></span>
<span>Only install APKs from a source you trust. Android will ask you to allow installs from your browser.</span>
</div>
<div class="links">
<a href="https://git.skate-spots.org/skatespots/SkateSpots/releases">All releases</a>
<a href="https://git.skate-spots.org/skatespots/SkateSpots">Source code</a>
<a href="https://skate-spots.org">Open the web app</a>
</div>
<div class="meta">
<span><strong>Obtanium:</strong> add the latest release page above as the app source, or select <em>Override Source → HTML</em> if it is not recognised.</span>
<span><strong>F-Droid repo:</strong> <a href="https://fdroid.skate-spots.org/repo">https://fdroid.skate-spots.org/repo</a> (third-party self-hosted repo; app ID <code>org.skatespots.app</code>).</span>
<span>Repo signing fingerprint: <code>88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75</code></span>
</div>
</main>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
public/images/brand-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/images/brand-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/images/brand-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 60 KiB

BIN
public/images/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
public/images/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
public/images/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

BIN
public/images/og-card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

BIN
public/images/opensats.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -1,8 +1,8 @@
{ {
"name": "SkateSpots - Decentralized Skate Map", "name": "SkateSpots — Open-sourced Skate Community App",
"short_name": "SkateSpots", "short_name": "SkateSpots",
"id": "/", "id": "/",
"description": "Discover skate spots, find coaches, join events, and connect with the skateboarding community on Nostr. Free, decentralized, censorship-resistant.", "description": "Map, crews, events, gear and more — an open-sourced skate community app.",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
@ -18,20 +18,26 @@
"related_applications": [], "related_applications": [],
"icons": [ "icons": [
{ {
"src": "/images/icon-1024.png", "src": "/images/brand-192.png",
"sizes": "180x180", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/images/icon-maskable-1024.png", "src": "/images/brand-512.png",
"sizes": "180x180", "sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/brand-maskable-512.png",
"sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/images/icon-1024.png", "src": "/images/brand-1024.png",
"sizes": "180x180", "sizes": "1024x1024",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
} }
@ -60,7 +66,7 @@
"url": "/map", "url": "/map",
"icons": [ "icons": [
{ {
"src": "/images/icon-1024.png", "src": "/images/brand-180.png",
"sizes": "180x180", "sizes": "180x180",
"type": "image/png" "type": "image/png"
} }
@ -73,7 +79,7 @@
"url": "/events", "url": "/events",
"icons": [ "icons": [
{ {
"src": "/images/icon-1024.png", "src": "/images/brand-180.png",
"sizes": "180x180", "sizes": "180x180",
"type": "image/png" "type": "image/png"
} }
@ -86,7 +92,7 @@
"url": "/coaches", "url": "/coaches",
"icons": [ "icons": [
{ {
"src": "/images/icon-1024.png", "src": "/images/brand-180.png",
"sizes": "180x180", "sizes": "180x180",
"type": "image/png" "type": "image/png"
} }
@ -99,7 +105,7 @@
"url": "/wallet", "url": "/wallet",
"icons": [ "icons": [
{ {
"src": "/images/icon-1024.png", "src": "/images/brand-180.png",
"sizes": "180x180", "sizes": "180x180",
"type": "image/png" "type": "image/png"
} }

View file

@ -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(

View file

@ -1,3 +1,606 @@
🛹 OpenStreetMap tiles load again
- OSM was refusing the app because tile requests sent no Referer. The app now sends only the site origin (strict-origin-when-cross-origin), which satisfies OSM's tile policy without exposing page paths.
- A fresh osm-v3 tile cache means previously cached "Access blocked" tiles are never reused.
- OpenStreetMap remains the basemap; no CARTO or other provider was added.
---
🛹 Orange wheel icon restored
- The app header, mobile header, splash screen and footer now use the orange SkateSpots wheel again.
- The same orange artwork is used for the PWA icons, Apple touch icon, app install metadata and Zapstore listing.
- Refreshed the social preview card with the open-sourced Skate Community App wording.
---
🛹 OpenStreetMap tiles kept, cache fixed
- OpenStreetMap remains the basemap.
- Bumped the tile-cache namespace so previously cached OSM "Access blocked" tiles can never be reused.
- The map still depends on OSM's public tile service; if OSM blocks the current network/VPN range, the tiles cannot load until the network changes.
---
🛹 Grantmaker inquiry, reworked
- The Bitcoin Grantmakers tab is now directly shareable at /supporters?tab=grants.
- The form is progressive: organization, contact and confirmation first; optional grant details are collapsed.
- Added website validation, clearer contact guidance, required-state messaging and a review dialog before the private DM opens.
- You can now copy the exact grant message as a fallback, with a clearer success/failure state.
- Added concrete examples of what a grant can fund, better contrast and larger touch targets.
- Shortened the mobile tab to Grants and softened the grantmaker copy.
---
🛹 Community and relay hygiene
- The community room invite now uses the canonical /community link and its generated QR.
- Removed the retired goskatespots.nostr1.com relay from active CVM, repository and NIP-65 relay lists. It remains only in the blocklist that prunes old client configs.
---
🛹 Marketplace UX overhaul
- Search, category, listing type, fulfilment, trust and sorting now live in the URL, so refresh, Back/Forward, shared links and saved searches keep their context.
- Mobile browsing gets a proper filter sheet and removable active-filter chips instead of a wall of category buttons.
- Price sorting now normalizes BTC, sats and fiat to the same value before comparing; listings without an available rate sort last.
- Marketplace cards are real links again: keyboard activation, open-in-new-tab, middle-click and the browser context menu all work.
- Listing pages get a mobile action sheet for buying, contacting, offers, escrow and reporting, plus a sticky price/action bar.
- Contact and share links are canonical and safe to share from the Android app.
- Create-listing forms now autosave a local draft, restore it after refresh or accidental navigation, and offer a discard action.
- Added a live listing preview before publishing, plus photo cover selection and left/right reordering.
- The marketplace now supports Show More instead of silently stopping at the first 200 listings.
- Unknown category links now fall back safely instead of producing an empty filter state.
---
🔐 Nsec is no longer stored persistently
- Raw nsec logins now live only in sessionStorage. Reloading the tab keeps the
session; closing the browser/app requires re-entering the key.
- Existing raw nsec entries are migrated out of localStorage on the next write.
- The login warning now says this explicitly and points to Amber/bunker for
staying signed in. Added a regression test that asserts no `nsec1` value lands
in persistent localStorage.
- Direct links to `/spot`, `/crew`, `/event`, `/listing`, `/card` and similar
paths now rewrite into the HashRouter before React mounts, including cold
native App Links.
---
👛 Built-in wallet first on mobile
- On the native app, the Wallet page and wallet manager now lead with the
built-in SkateSpots ecash wallet. Connecting an existing Lightning wallet is
the secondary path.
- The web build keeps the connected-wallet card first because Cashu minting is
native-only.
- Slightly larger bottom-nav labels for readability on phones.
---
👛 A simpler wallet setup
- Removed the four-wallet recommendation list from the primary wallet flow.
- Removed the “Browser Extension · Not Found” row; WebLN only appears when a
provider is actually detected.
- Removed the duplicate “Add Wallet” action when no wallet is connected. There
is now one clear “Connect Wallet” path.
- Added one neutral help line and a generic “how wallets work” link instead of
sending users through a brand directory.
---
📱 Phone/PWA install polish
- Added proper 192px and 512px app icons plus a 512px maskable icon, and a real
180px Apple touch icon. The manifest no longer advertises 180px images as
1024px.
- Fixed the signup recovery-code print popup to use the dynamic viewport height,
so it no longer overflows behind mobile browser chrome.
---
🧵 Crew coordinator routing is now correct
- A crew’s own coordinator is authoritative. If its pointer is incomplete
(custom relay without a service pubkey), SkateSpots no longer silently falls
back to the SkateSpots coordinator.
- Join-request notifications now poll each crew’s coordinator independently, so
crews running their own coordinator get request notifications too. One
unreachable custom coordinator no longer stops notifications for other crews.
- Device-link failures now show an inline error with a Retry button instead of a
permanently spinning dialog.
---
🏠 One command to run a crew coordinator
- Added `deploy/cordn/install.sh`: it generates the coordinator key, writes a
private `.env`, starts persistent SQLite storage, and prints the nprofile/add
link when the coordinator announces.
- Settings → Coordinator now shows that one-command installer instead of the
long Docker command.
- Documented the phone limit honestly: a phone can administer a coordinator or
use Cordn’s app, but an always-on crew coordinator belongs on a small server.
- No inbound port, domain or TLS terminator is required.
---
🔑 Simpler sign-in choices
- The login dialog now shows two primary methods instead of three:
**Browser extension** and a platform-aware signer card.
- On Android the signer card is **Amber (NIP-55)**. On desktop it is
**Pair a signer** via NIP-46 QR.
- Removed the duplicate NIP-46 card. Desktop QR pairing now contains the
“Paste a bunker URI instead” path, with a back link.
- Amber users on Android get a direct “Use another signer (QR)” escape hatch.
- Fixed the QR button markup and clarified that QR pairing is for NIP-46
signers such as nsec.app/Nostrum; Amber is the one-tap Android path.
---
🛡️ Verify before showing, and stop leaking referrers
- The shared skate-content query path now verifies every relay event's id and
signature before merge/display. Trust-sensitive reads no longer rely on the
relay implementation to have checked it.
- User images and avatars now use `referrerPolicy="no-referrer"`; the site
Referrer-Policy is `no-referrer` too, so third-party image hosts do not learn
which SkateSpots page you came from.
- These are defense-in-depth changes; no new user prompt or gate.
---
🎯 Status control no longer crowds the header
- The desktop status control is now a compact emoji-only round chip with a
tooltip, instead of a text pill that could collide with the profile controls.
- Removed the duplicate online dot from the mobile avatar; the status indicator
is the single source of truth there.
- Existing status dialog, avatar shortcut and mobile menu behavior are unchanged.
---
🛡️ Amber is quieter and safer
- Encrypted-message inboxes no longer auto-decrypt on arrival for Amber users.
Messages now show a deliberate “Unlock messages” card; opening Messages itself
does not fire a decrypt storm. The gate is Amber-only; silent signers keep the
automatic inbox.
- Amber redirect operations now expire after 10 minutes; stale callbacks are
rejected and cleared, so an old request cannot be replayed later.
- Redirect encryption operations no longer persist plaintext in localStorage,
sessionStorage or IndexedDB. Crypto results are no longer left in browser
storage.
- Added regression tests for stale Amber callback handling.
---
🎯 Quiet status control
- The desktop header no longer shows a large green outlined “Status” pill with a
permanent ping. It now shows a compact chip with your current status emoji and
label, or a neutral “Status” affordance when none is set.
- The control stays a single tap to open the status dialog; the existing avatar
status dot and mobile status flow are unchanged.
---
🧭 Coordinator setup that actually verifies itself
- Settings → Coordinator now runs a real ContextVM RPC probe, not just a
heartbeat presence check. It shows RPC latency when reachable and clearly
distinguishes “visible but RPC unavailable”.
- Crew edit now has an optional coordinator operator field (npub/hex), published
in the existing coordinator tag. The operator is a label, not an admin key.
- The RPC check uses a throwaway local key, so it never prompts Amber or touches
your identity.
- Added a live coordinator RPC test (skipped by default; `CORDN_LIVE=1`).
---
🏠 A real crew-owned coordinator kit
- Added `deploy/cordn/`: Docker Compose with SQLite persistence, `.env` template,
backup/restore scripts, and a runbook for a crew operator.
- The coordinator needs only outbound access to ContextVM relays — no inbound
port, domain or TLS terminator is required for the coordinator itself.
- Settings → Coordinator and the crew edit form now link to the production kit;
the displayed Docker command is persistent instead of the old `--rm` demo.
- The app still does not embed a coordinator. One crew-trusted coordinator is
the model; no multi-coordinator/BFT claims.
---
🎯 Value-aware safety and fresher reputation
- High-value fixed-price listings (100,000 sats or 100 USD/EUR/GBP) now get the
same compact safety note as new sellers: meet in public, prefer escrow for
shipped deals, never pay outside the app, and remember escrow is not
arbitration.
- Trade reputation now applies a 180-day half-life to the trust/activity weight,
while keeping lifetime counts and the existing distinct-partner/ring warnings.
The profile card also shows how many mutually-confirmed trades happened in the
last 90 days.
- Added the unit-tested NUT-11 2-of-3 escrow policy primitive (seller+arbiter /
buyer+arbiter). It is not exposed in the UI until an arbiter policy, bond and
appeal process exist.
---
🔎 Relay consistency and recent reputation
- Settings → Relays has an on-demand consistency check: it compares a bounded
profile snapshot from the community relay with your read relays and reports
newer/different signed profiles. It never merges data or treats relays as
voters.
- Trade reputation now shows how many mutually-confirmed trades happened in the
last 90 days, alongside the lifetime count and existing distinct-partner
warnings. Older reputation remains visible; this is a recency signal, not a
reset.
---
🛡️ A clearer warning before a new seller deal
- Listings from a seller with no confirmed trading partner, or an account under
30 days old, now show a compact safety note with the marketplace guidance:
meet in public, prefer escrow for shipped deals, and never pay outside the
app’s rails.
- The note sits beside the trade actions only — browsing stays frictionless.
---
🔒 Privacy, privacy, consistent merging
- Relay results are now merged deterministically with a newest-event rule and a
stable event-id tie-break instead of “first relay wins”. Local deletion
tombstones are applied during the same merge, so a deleted item cannot flash
back because a different relay answered first.
- Photo uploads are redrawn through a canvas before Blossom, stripping EXIF/GPS
metadata from decodable images. GIF and HEIC/HEIF still pass through as-is
because they need special handling.
- Android cloud backup is disabled for the app: local keys, settings and pending
escrow state stay on the device and must be exported explicitly.
- The OpenSats footer icon is now served locally, so opening the app does not
contact opensats.org just to render the footer.
- Added a technical threat-model document and linked it from the trust page:
signed facts, no relay consensus, no delivery proof, no arbitration.
---
🧹 Small correctness pass
- Unknown routes now return home with a real in-app link, so the native app no
longer does a full page reload on a 404.
- Profile badge links are sanitized before rendering, blocking `javascript:` and
other unsafe URL schemes from user metadata.
- Marketplace payment readiness now only says “ready” when checkout has a
Lightning address it can actually pay; an LNURL-only profile is no longer
advertised as ready.
- Removed the duplicate North Korean option from the language picker while
keeping stored preferences valid.
- The beta screen’s “View the code” link now points at the self-hosted source
instead of a GitHub search.
---
🧩 Cordn setup is easier to follow
- The setup steps now show progress: preparing this device is checked once the
key package is ready, and the approval step shows a spinner while waiting.
- The private-chat header already shows the last successful check, so “live” and
“still waiting” are distinguishable at a glance.
---
⚡ Cordn status, payment readiness, lighter photos
- Cordn panels now show the last successful check next to the connection status,
so “waiting” and “live” are easier to tell apart.
- Sellers see a payment-readiness card in listing creation and on incoming
offers, with a direct link to add a Lightning address before accepting a deal.
- Photos are resized to max 1600px and converted to WebP before upload when that
makes them smaller. GIFs and formats the WebView cannot decode are left alone.
This cuts upload time, quota use and Blossom storage.
---
🧹 Spot owners can delete from the card menu
- Owner/admin members now get a **Delete** action in the three-dot menu on
spot and shop cards, opening the existing confirmation dialog.
- Added a mobile **Back** arrow on every non-map screen, with history fallback
to the map. The bottom Spots tab remains the home shortcut.
- Trimmed the avatar account menu to purpose-built actions: status, profile,
settings, account switching and logout. The larger navigation stays in the
burger menu.
- Brand wordmark hides on sub-pages so the Back arrow and header controls fit
on narrow phones without truncation.
---
🧭 Brand logo opens your profile
- The top-left SkateSpots lockup now opens your public profile when signed in
(both mobile and desktop). Signed out, it still goes to the map.
- The bottom Spots tab / desktop Map tab remains the home shortcut, so map
access is unchanged.
- The green avatar still opens your profile too; its chevron opens the account
menu.
---
👤 Avatar opens your profile
- The green user avatar in the mobile header now opens your public profile.
- The small chevron beside it opens the account menu (status, add account,
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
- Native installs now clear any old app-shell service worker and
`skate-spots-shell-*` cache from `https://localhost` before the bundle runs.
A stale worker could serve an old index.html whose hashed chunks no longer
existed, causing the white/black reload loop after an upgrade.
- Web installs are unchanged; the app-shell worker still updates normally.
---
🧪 Zero known production vulnerabilities + profile services
- Replaced the write-only SheetJS xlsx package with a small local OOXML writer.
`npm audit --omit=dev` is now **0 vulnerabilities**.
- Personal profiles now show a **Lab services** strip linking to their
Transport, coaching, design, building and other Lab profiles.
- Added tests for the Transport parser and XLSX writer (156 tests total).
---
🔍 Audit pass: counts, forms, dependencies
- Followers now query up to 500 follow lists instead of 100, so profile and
sponsor counts are less likely to undercount.
- Clothing designers were missing from /stats and Supporters impact; they now
have their own count and card (15 Lab crafts total).
- The floating Sasquatch bubble is hidden on /become forms, create/edit forms
and /supporters so it can't cover inputs or the grantmaker form.
- protobufjs override moved to 7.6.5, clearing the moderate ONNX-path advisory.
The only remaining production advisory is xlsx (write-only export, no fix
published).
---
🛠 Cordn empty-response fix
- The coordinator client now accepts both structuredContent and JSON text
responses, and treats an empty list response as an empty list. This fixes the
“expected object, received undefined” error when a coordinator omits the
structured field on an empty join-request/welcome/message result.
---
🚐 Transport / Driver profiles in The Lab
- New Transport craft for skaters who move people, gear or cargo:
cars, taxis, vans, motorcycles, helicopters, planes and boats.
- Services: people, goods/cargo, skate gear, airport and venue transfers,
tours and event logistics.
- Capabilities, capacity and coverage filters on /lab: cargo, bike/board rack,
wheelchair access, pets, cross-border, night driving, covered, group size,
local/regional/national/international.
- Capacity, cargo, coverage, availability, base area and accessibility fields.
- WoT badge, crew link, vouches, reviews and the normal report/hide flow.
- Explicitly self-attested: SkateSpots does not verify licences, insurance,
vehicle roadworthiness or operator permits. A visible notice says so.
- Transport counts on /stats and Supporters impact.
- All Transport copy is in all 24 languages at 100%.
---
🗺 Cleaner map footer + follower stat fit
- The grey floating “Links” pill between Events and Crews is gone. The map
footer is now a slim, always-visible strip above the bottom nav.
- Profile stats use 2/3/6 responsive columns; the Followers label and caveat
no longer overflow or open an edge tooltip.
- The Bitcoin grantmaker section now states its ethos and privacy boundaries:
no KYC of SkateSpots, no tracking/CRM, private NIP-17 DM, and only grants
that keep the project open source, self-hosted and analytics-free.
---
🧾 Grantmaker transparency + stat fit
- The Followers stat no longer overflows its card: stats use 2/3/6 columns,
and the relay-reported caveat is a proper footnote instead of an edge tooltip.
- The Bitcoin grantmaker section now spells out the ethos and the boundaries:
no KYC of SkateSpots, no tracking/CRM, private NIP-17 DM, and only grants
that keep the project open source, self-hosted and analytics-free.
---
💸 Sponsorship opt-in + Bitcoin grantmakers
- Profiles have an explicit “Open to sponsorships” switch and optional note;
the badge and sponsor card only claim it after the skater opts in.
- The Supporters page now has a Bitcoin grantmaker inquiry form. It opens a
private DM to the owner and stores nothing: not to KYC us — to KYC YOU.
It is only for grantmakers that KYC/KYB their grantees.
- The Followers stat carries an info tooltip: relay-reported, not audited
analytics.
---
👤 Sponsor-ready profiles
- Profiles now have Follow/Unfollow, Followers and Following counts, and an
Events hosted section.
- New sponsor/collab card shows followers, events, spots and zaps received,
with a prefilled collab DM and a direct link to the skater's Rider Card.
- Stats strip is a balanced six cells: Spots, Shops, Crews, Events, Gear and
Followers.
- NIP-05 now shows a real verifying state instead of briefly looking unverified.
- Public stash images use the same resilient image fallback as the rest of the app.
---
🛹 Rolling logo, Cordn inbox, safer sends 🛹 Rolling logo, Cordn inbox, safer sends
- The app now opens on the orange SkateSpots wheel: it rolls in, keeps - The app now opens on the orange SkateSpots wheel: it rolls in, keeps
spinning and throws sparks. Reduced-motion users get the clean static mark. spinning and throws sparks. Reduced-motion users get the clean static mark.

BIN
resources/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -55,6 +55,8 @@ interface Crew {
pubkey: string; pubkey: string;
identifier: string; identifier: string;
recipients: string[]; recipients: string[];
coordinatorPubkey: string;
coordinatorRelays: string[];
} }
interface WatcherState { interface WatcherState {
@ -130,8 +132,18 @@ function crewFromEvent(event: {
const name = event.tags.find(([name]) => name === 'title')?.[1]; const name = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !name) return null; if (!d || !name) return null;
const coordinatorTag = event.tags.find(([name]) => name === 'coordinator'); const coordinatorTag = event.tags.find(([name]) => name === 'coordinator');
const coordinatorPubkey = coordinatorTag?.[2]; const coordinatorUrl = coordinatorTag?.[1];
if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null; const configuredPubkey = coordinatorTag?.[2];
const isDefaultRelay = !!coordinatorUrl && coordinatorUrl === SKATESPOTS_COORDINATOR.relays[0];
// A custom relay without a service pubkey is not addressable. Do not fall
// back to SkateSpots' coordinator on someone else's relay.
if (coordinatorUrl && !configuredPubkey && !isDefaultRelay) return null;
const coordinatorPubkey = configuredPubkey ?? SKATESPOTS_COORDINATOR.pubkey;
if (!/^[0-9a-f]{64}$/i.test(coordinatorPubkey)) return null;
const coordinatorRelays =
coordinatorUrl && /^wss?:\/\//i.test(coordinatorUrl)
? [coordinatorUrl]
: SKATESPOTS_COORDINATOR.relays;
const admins = event.tags const admins = event.tags
.filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin') .filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin')
.map(([, value]) => value.toLowerCase()); .map(([, value]) => value.toLowerCase());
@ -142,6 +154,8 @@ function crewFromEvent(event: {
pubkey: event.pubkey, pubkey: event.pubkey,
identifier: d, identifier: d,
recipients, recipients,
coordinatorPubkey,
coordinatorRelays,
}; };
} }
@ -264,22 +278,44 @@ async function main(): Promise<void> {
return; return;
} }
const coordinator = new CordnCoordinatorClient({ const privateKeyHex = Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join('');
serverPubkey: SKATESPOTS_COORDINATOR.pubkey, const groupsByCoordinator = new Map<string, { pubkey: string; relays: string[]; crews: Crew[] }>();
relays: SKATESPOTS_COORDINATOR.relays, for (const crew of crews) {
privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''), const key = `${crew.coordinatorPubkey}|${crew.coordinatorRelays.slice().sort().join(',')}`;
}); const group = groupsByCoordinator.get(key) ?? {
pubkey: crew.coordinatorPubkey,
relays: crew.coordinatorRelays,
crews: [],
};
group.crews.push(crew);
groupsByCoordinator.set(key, group);
}
let requests: FetchManyPendingJoinRequestsOutput; const requests: FetchManyPendingJoinRequestsOutput['requests'] = [];
try { for (const group of groupsByCoordinator.values()) {
requests = await coordinator.fetchManyPendingJoinRequests({ const coordinator = new CordnCoordinatorClient({
groups: crews.map((crew) => ({ gid: crew.gid })), serverPubkey: group.pubkey,
relays: group.relays,
privateKeyHex,
}); });
try {
const result = await coordinator.fetchManyPendingJoinRequests({
groups: group.crews.map((crew) => ({ gid: crew.gid })),
});
requests.push(...result.requests);
} catch (error) {
// A custom coordinator can be offline/private while other crews are
// healthy. Keep polling the rest instead of failing the whole watcher.
console.error(
`[cordn-watcher] coordinator ${shortNpub(group.pubkey)} unavailable:`,
error instanceof Error ? error.message : String(error),
);
} finally { } finally {
await coordinator.disconnect().catch(() => undefined); await coordinator.disconnect().catch(() => undefined);
} }
}
if (requests.requests.length === 0) { if (requests.length === 0) {
pool.close([COMMUNITY]); pool.close([COMMUNITY]);
console.log('[cordn-watcher] no pending join requests'); console.log('[cordn-watcher] no pending join requests');
return; return;
@ -289,7 +325,7 @@ async function main(): Promise<void> {
const state = loadState(); const state = loadState();
let sent = 0; let sent = 0;
for (const request of requests.requests) { for (const request of requests) {
const crew = byGid.get(request.gid); const crew = byGid.get(request.gid);
if (!crew) continue; if (!crew) continue;
const key = `${request.gid}:${request.pk}:${request.kp_ref}`; const key = `${request.gid}:${request.pk}:${request.kp_ref}`;
@ -342,7 +378,7 @@ async function main(): Promise<void> {
{ {
watcher: nip19.npubEncode(watcherPubkey), watcher: nip19.npubEncode(watcherPubkey),
crews: crews.length, crews: crews.length,
pending: requests.requests.length, pending: requests.length,
newRequests: sent, newRequests: sent,
}, },
null, null,

View file

@ -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=""

View 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).');
}

View file

@ -15,7 +15,12 @@
* 1. Removes any previously managed signing block. * 1. Removes any previously managed signing block.
* 2. Removes a legacy hardcoded signingConfigs block (inline passwords). * 2. Removes a legacy hardcoded signingConfigs block (inline passwords).
* 3. Writes a managed signingConfigs block into android/app/build.gradle that * 3. Writes a managed signingConfigs block into android/app/build.gradle that
* reads secrets from the gitignored keystore.properties at the repo root. * reads secrets from a keystore.properties file outside the repo.
*
* The signing material is deliberately outside the Vite project root:
* ~/.config/skatespots/android/keystore.properties
* ~/.config/skatespots/android/skatespots-release.jks
* Override the properties path with SKATESPOTS_KEYSTORE_PROPERTIES.
* *
* It exits quietly (0) when android/ isn't generated yet, or when * It exits quietly (0) when android/ isn't generated yet, or when
* keystore.properties is absent — so debug-only builds without a keystore still * keystore.properties is absent — so debug-only builds without a keystore still
@ -23,11 +28,16 @@
*/ */
import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { homedir } from 'node:os';
const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const buildGradle = join(root, 'android', 'app', 'build.gradle'); const buildGradle = join(root, 'android', 'app', 'build.gradle');
const keystoreProps = join(root, 'keystore.properties'); const keystoreProps = resolve(
process.env.SKATESPOTS_KEYSTORE_PROPERTIES ??
join(homedir(), '.config', 'skatespots', 'android', 'keystore.properties'),
);
const gradleString = (value) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const BEGIN = '// >>> managed by scripts/inject-signing-config.mjs'; const BEGIN = '// >>> managed by scripts/inject-signing-config.mjs';
const END = '// <<< managed by scripts/inject-signing-config.mjs <<<'; const END = '// <<< managed by scripts/inject-signing-config.mjs <<<';
@ -43,10 +53,11 @@ if (!existsSync(keystoreProps)) {
const managedLines = [ const managedLines = [
`${BEGIN} >>>`, `${BEGIN} >>>`,
'// Release signing is read from keystore.properties at the repo root', '// Release signing is read from keystore.properties outside the repo.',
'// (gitignored). Edit that file, not this block.', '// Edit that file, not this block.',
'def _keystoreProps = new Properties()', 'def _keystoreProps = new Properties()',
"def _keystorePropsFile = rootProject.file('../keystore.properties')", "def _keystorePropsPath = System.getenv('SKATESPOTS_KEYSTORE_PROPERTIES')",
`def _keystorePropsFile = new File(_keystorePropsPath ?: '${gradleString(keystoreProps)}')`,
'if (_keystorePropsFile.exists()) {', 'if (_keystorePropsFile.exists()) {',
' _keystorePropsFile.withInputStream { _keystoreProps.load(it) }', ' _keystorePropsFile.withInputStream { _keystoreProps.load(it) }',
'}', '}',

View file

@ -218,12 +218,23 @@ p ""
p "${B}Community bot${N}" p "${B}Community bot${N}"
COMMUNITY_HOST="${SKATESPOTS_COMMUNITY_HOST:-skatespots-cvm}" COMMUNITY_HOST="${SKATESPOTS_COMMUNITY_HOST:-skatespots-cvm}"
if command -v ssh >/dev/null 2>&1 && grep -qsE "^Host[[:space:]]+${COMMUNITY_HOST}([[:space:]]|$)" "$HOME/.ssh/config"; then if command -v ssh >/dev/null 2>&1 && grep -qsE "^Host[[:space:]]+${COMMUNITY_HOST}([[:space:]]|$)" "$HOME/.ssh/config"; then
# A failed SSH connection is NOT evidence that the remote bot is down. The
# previous version turned an empty SSH result into "unreachable" and emitted
# an ops notification on every transient network/DNS hiccup. Retry once, then
# report an honest unknown state without alerting.
community_state=""
for attempt in 1 2; do
community_state="$(timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 "$COMMUNITY_HOST" 'systemctl is-active skatespots-community' 2>/dev/null || true)" community_state="$(timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 "$COMMUNITY_HOST" 'systemctl is-active skatespots-community' 2>/dev/null || true)"
if [ "$community_state" = "active" ]; then [ -n "$community_state" ] && break
[ "$attempt" = "1" ] && sleep 1
done
if [ -z "$community_state" ]; then
p " ${Y}⚠${N} skatespots-community: health check unreachable (SSH failed; state unknown)"
elif [ "$community_state" = "active" ]; then
p " ${G}✓${N} skatespots-community active" p " ${G}✓${N} skatespots-community active"
else else
p " ${R}✗${N} skatespots-community: ${community_state:-unreachable}" p " ${R}✗${N} skatespots-community: ${community_state}"
problems+=("community Cordn bot is ${community_state:-unreachable}") problems+=("community Cordn bot is ${community_state}")
signature+="community-bot;" signature+="community-bot;"
fi fi
community_backup="$(systemctl --user is-active skatespots-community-backup.timer 2>/dev/null || echo unknown)" community_backup="$(systemctl --user is-active skatespots-community-backup.timer 2>/dev/null || echo unknown)"

108
scripts/publish-forgejo.sh Executable file
View 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
View 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}`));

View file

@ -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

View 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

View file

@ -16,7 +16,7 @@ import NostrProvider from '@/components/NostrProvider';
import { NostrSync } from '@/components/NostrSync'; import { NostrSync } from '@/components/NostrSync';
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { NostrLoginProvider } from '@nostrify/react/login'; import { NostrLoginProvider } from '@/lib/nostrLogin';
import { AppProvider } from '@/components/AppProvider'; import { AppProvider } from '@/components/AppProvider';
import { NWCProvider } from '@/contexts/NWCContext'; import { NWCProvider } from '@/contexts/NWCContext';
import { LanguageProvider } from '@/contexts/LanguageContext'; import { LanguageProvider } from '@/contexts/LanguageContext';

View file

@ -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"));
@ -77,7 +79,6 @@ const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage")); const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage"));
const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage")); const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage"));
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage")); const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage"));
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage")); const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage")); const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage"));
const NotFound = lazyWithRetry(() => import("./pages/NotFound")); const NotFound = lazyWithRetry(() => import("./pages/NotFound"));
@ -99,6 +100,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 +113,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 +159,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,9 +195,11 @@ 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={<Navigate to="/marketplace" replace />} />
{/* Newsletter */} {/* Newsletter */}
<Route path="/newsletter" element={<NewsletterPage />} /> <Route path="/newsletter" element={<NewsletterPage />} />
{/* Trick Glossary */} {/* Trick Glossary */}

View file

@ -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>
); );
} }

View file

@ -1,7 +1,7 @@
import { KeyRound } from 'lucide-react'; import { KeyRound } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@/lib/nostrLogin';
import { isInteractiveSigner } from '@/lib/signerCapabilities'; import { isInteractiveSigner } from '@/lib/signerCapabilities';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';

View file

@ -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);

View file

@ -13,13 +13,15 @@ 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';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { import {
CORDN_APP_URL, CORDN_APP_URL,
CORDN_DOCKER_COMMAND, CORDN_INSTALL_COMMAND,
CORDN_DEPLOY_KIT_URL,
CORDN_RELAY_URL, CORDN_RELAY_URL,
coordinatorAddUrl, coordinatorAddUrl,
parseCoordinatorInput, parseCoordinatorInput,
@ -27,6 +29,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 +62,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.
* *
@ -70,7 +95,7 @@ async function copyText(text: string, copiedText: string, failedTitle: string, f
export function CoordinatorSettingsCard() { export function CoordinatorSettingsCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator(); const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator); const { status, lastSeen, eventCount, name, error, rpcStatus, rpcLatencyMs, check } = useCoordinatorHealth(coordinator);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [showQr, setShowQr] = useState(false); const [showQr, setShowQr] = useState(false);
@ -119,6 +144,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(
@ -146,6 +176,17 @@ export function CoordinatorSettingsCard() {
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)} : ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
</p> </p>
{rpcStatus === 'ok' && (
<p className="text-[11px] font-medium text-green-400">
{t('socialUi.coordinator.rpcOk', { ms: rpcLatencyMs ?? 0 })}
</p>
)}
{rpcStatus === 'error' && (
<p className="text-[11px] text-amber-400">
{t('socialUi.coordinator.rpcError')}
</p>
)}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}> <Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} /> <RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
@ -274,17 +315,25 @@ export function CoordinatorSettingsCard() {
</p> </p>
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2"> <div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300"> <code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
{CORDN_DOCKER_COMMAND} {CORDN_INSTALL_COMMAND}
</code> </code>
<button <button
type="button" type="button"
onClick={() => void copyText(CORDN_DOCKER_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))} onClick={() => void copyText(CORDN_INSTALL_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
className="shrink-0 text-muted-foreground hover:text-white transition-colors" className="shrink-0 text-muted-foreground hover:text-white transition-colors"
aria-label={t('socialUi.coordinator.copyCommand')} aria-label={t('socialUi.coordinator.copyCommand')}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<a href={CORDN_DEPLOY_KIT_URL} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
{t('common.learnMore')}
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</Button>
</a>
</div>
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed"> <p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /> <AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t('socialUi.coordinator.androidSleep')} {t('socialUi.coordinator.androidSleep')}

View file

@ -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) {

View file

@ -13,7 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@/lib/nostrLogin';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { AmberApprovalHint } from '@/components/AmberApprovalHint'; import { AmberApprovalHint } from '@/components/AmberApprovalHint';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';

View file

@ -23,12 +23,13 @@ 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';
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR } from '@/lib/coordinator'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, parseOperatorPubkey, shortNpub, SKATESPOTS_COORDINATOR, CORDN_DEPLOY_KIT_URL } from '@/lib/coordinator';
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react';
@ -335,6 +336,7 @@ export function CrewForm({ existing }: CrewFormProps) {
crewCoordinatorInputValue(existing?.coordinator) || crewCoordinatorInputValue(existing?.coordinator) ||
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })), (isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
); );
const [coordinatorOperator, setCoordinatorOperator] = useState(existing?.coordinator?.operator ?? '');
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill. // The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
const { coordinator: myCoordinator } = useCoordinator(); const { coordinator: myCoordinator } = useCoordinator();
// Live parse so the founder gets feedback (and the publish path reuses it). // Live parse so the founder gets feedback (and the publish path reuses it).
@ -493,6 +495,11 @@ export function CrewForm({ existing }: CrewFormProps) {
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' }); toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
return; return;
} }
const operatorPubkey = coordinatorOperator.trim() ? parseOperatorPubkey(coordinatorOperator) : null;
if (coordinatorOperator.trim() && !operatorPubkey) {
toast({ title: t('socialUi.coordinator.invalidOperator'), variant: 'destructive' });
return;
}
try { try {
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`; const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
@ -571,7 +578,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) {
@ -613,6 +620,10 @@ export function CrewForm({ existing }: CrewFormProps) {
if (parsedCoordinator) { if (parsedCoordinator) {
const coordinatorTag = ['coordinator', parsedCoordinator.url]; const coordinatorTag = ['coordinator', parsedCoordinator.url];
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey); if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
if (operatorPubkey) {
if (!parsedCoordinator.pubkey) coordinatorTag.push('');
coordinatorTag.push(operatorPubkey);
}
tags.push(coordinatorTag); tags.push(coordinatorTag);
} }
@ -1183,6 +1194,15 @@ export function CrewForm({ existing }: CrewFormProps) {
> >
{t('socialUi.coordinator.cordnCoordinator')} {t('socialUi.coordinator.cordnCoordinator')}
</a> </a>
{' · '}
<a
href={CORDN_DEPLOY_KIT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
>
{t('common.learnMore')}
</a>
. .
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@ -1232,6 +1252,21 @@ export function CrewForm({ existing }: CrewFormProps) {
</Button> </Button>
)} )}
<div className="space-y-1.5">
<Label htmlFor="crew-coordinator-operator" className="text-xs text-gray-400">
{t('socialUi.coordinator.operatorLabel')}
</Label>
<Input
id="crew-coordinator-operator"
placeholder={t('socialUi.coordinator.operatorPlaceholder')}
value={coordinatorOperator}
onChange={(e) => setCoordinatorOperator(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
spellCheck={false}
/>
<p className="text-[10px] text-gray-600">{t('socialUi.coordinator.operatorHint')}</p>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label> <Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
<Input <Input

View file

@ -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>

View file

@ -1002,10 +1002,10 @@ export function DeckCostCalculator() {
a.click(); a.click();
}; };
// Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand). // Multi-sheet XLSX workbook (lazy — our tiny local OOXML writer is only
// loaded when the user asks for the spreadsheet).
const exportXlsx = async () => { const exportXlsx = async () => {
const XLSX = await import('xlsx'); const { downloadXlsx } = await import('@/lib/xlsx');
const wb = XLSX.utils.book_new();
const num = (v: number) => Number(v.toFixed(4)); const num = (v: number) => Number(v.toFixed(4));
const summary: (string | number)[][] = [ const summary: (string | number)[][] = [
[t('legacyMiscUi.exportTitle')], [t('legacyMiscUi.exportTitle')],
@ -1024,24 +1024,29 @@ export function DeckCostCalculator() {
[t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)], [t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)],
[t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)], [t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)],
]; ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), t('legacyMiscUi.exportXlsxSummary')); const equipment: (string | number)[][] = [
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([
[t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]), ...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]),
]), t('legacyMiscUi.exportSectionEquipment')); ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ const materials: (string | number)[][] = [
[t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')],
...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]), ...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]),
]), t('legacyMiscUi.exportSectionMaterials')); ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ const processes: (string | number)[][] = [
[t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')],
...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]), ...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]),
]), t('legacyMiscUi.exportLabelProcesses')); ];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ const packaging: (string | number)[][] = [
[t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')], [t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')],
...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]), ...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]),
]), t('legacyMiscUi.exportSectionPackaging')); ];
XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`); downloadXlsx(`deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`, [
{ name: t('legacyMiscUi.exportXlsxSummary'), rows: summary },
{ name: t('legacyMiscUi.exportSectionEquipment'), rows: equipment },
{ name: t('legacyMiscUi.exportSectionMaterials'), rows: materials },
{ name: t('legacyMiscUi.exportLabelProcesses'), rows: processes },
{ name: t('legacyMiscUi.exportSectionPackaging'), rows: packaging },
]);
}; };
const copySummary = async () => { const copySummary = async () => {

View file

@ -20,7 +20,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot, BadgeCheck, HelpCircle, Server, AtSign } from 'lucide-react'; import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, Handshake, X, Plus, Bot, BadgeCheck, HelpCircle, Server, AtSign } from 'lucide-react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
@ -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.
@ -72,6 +73,8 @@ export const EditProfileForm: React.FC = () => {
// pointer formats as Settings → Coordinator (nprofile / cordn.net link / // pointer formats as Settings → Coordinator (nprofile / cordn.net link /
// npub / hex) as well as an http(s) URL — this is the discovery field. // npub / hex) as well as an http(s) URL — this is the discovery field.
const [cordn, setCordn] = useState(''); const [cordn, setCordn] = useState('');
const [openToSponsorships, setOpenToSponsorships] = useState(false);
const [sponsorNote, setSponsorNote] = useState('');
// Extra personal NIP-05 handles — stored in a separate kind-30078 record so // Extra personal NIP-05 handles — stored in a separate kind-30078 record so
// another client re-saving the profile can't drop them. // another client re-saving the profile can't drop them.
@ -131,6 +134,8 @@ export const EditProfileForm: React.FC = () => {
); );
} }
if (typeof raw.cordn === 'string') setCordn(raw.cordn); if (typeof raw.cordn === 'string') setCordn(raw.cordn);
setOpenToSponsorships(raw.sponsorships_open === true);
if (typeof raw.sponsorships_note === 'string') setSponsorNote(raw.sponsorships_note);
} }
}, [metadata, form]); }, [metadata, form]);
@ -220,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;
} }
@ -252,6 +257,17 @@ export const EditProfileForm: React.FC = () => {
// Retire the legacy field now that badges supersede it. // Retire the legacy field now that badges supersede it.
delete data.sponsors; delete data.sponsors;
// Sponsorship opt-in (custom kind-0 fields). The profile only claims
// this when the skater explicitly turned it on.
if (openToSponsorships) {
data.sponsorships_open = true;
if (sponsorNote.trim()) data.sponsorships_note = sponsorNote.trim();
else delete data.sponsorships_note;
} else {
delete data.sponsorships_open;
delete data.sponsorships_note;
}
// Cordn contact link (custom field). // Cordn contact link (custom field).
if (cordnTrimmed) { if (cordnTrimmed) {
// Normalise a bare pointer (nprofile/npub/hex) to Cordn's canonical // Normalise a bare pointer (nprofile/npub/hex) to Cordn's canonical
@ -402,6 +418,41 @@ export const EditProfileForm: React.FC = () => {
)} )}
/> />
{/* Sponsorship opt-in — explicit consent, never inferred from activity. */}
<div className="space-y-3 rounded-xl border border-pink-500/20 bg-pink-500/[0.04] p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<Label htmlFor="profile-open-sponsorships" className="flex items-center gap-2 text-sm font-semibold">
<Handshake className="h-4 w-4 text-pink-400" />
{t('settingsUi.editProfile.openToSponsorships')}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t('settingsUi.editProfile.openToSponsorshipsHint')}
</p>
</div>
<Switch
id="profile-open-sponsorships"
checked={openToSponsorships}
onCheckedChange={setOpenToSponsorships}
/>
</div>
{openToSponsorships && (
<div className="space-y-1.5">
<Label htmlFor="profile-sponsor-note" className="text-xs font-bold uppercase tracking-wide text-pink-300">
{t('settingsUi.editProfile.sponsorNote')}
</Label>
<Textarea
id="profile-sponsor-note"
value={sponsorNote}
onChange={(event) => setSponsorNote(event.target.value)}
placeholder={t('settingsUi.editProfile.sponsorNotePlaceholder')}
maxLength={180}
rows={2}
/>
</div>
)}
</div>
{/* NIP-05 — the verified-handle field. Given real prominence + live {/* NIP-05 — the verified-handle field. Given real prominence + live
verification feedback so people know if it's actually working. */} verification feedback so people know if it's actually working. */}
<FormField <FormField
@ -443,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') }
@ -663,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>
@ -676,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' ? (

View file

@ -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>
); );

View file

@ -1,10 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -52,6 +53,13 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
const [showMarkSold, setShowMarkSold] = useState(false); const [showMarkSold, setShowMarkSold] = useState(false);
const npub = nip19.npubEncode(item.pubkey); const npub = nip19.npubEncode(item.pubkey);
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
// Keep shared links canonical in the native WebView too (capacitor://localhost
// is not a shareable origin).
const listingUrl = `https://skate-spots.org${listingPath}`;
const contactSeller = () => navigate(
`/messages/${npub}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
);
const isOwnListingCheck = user?.pubkey === item.pubkey; const isOwnListingCheck = user?.pubkey === item.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey); const isAdmin = isAdminPubkey(user?.pubkey);
@ -127,21 +135,28 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
return ( return (
<Card <Card
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] h-full ${ className={`group relative h-full border-2 bg-black/90 transition-all hover:scale-[1.02] ${
isWTB isWTB
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]' ? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]' : 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
}`} }`}
onClick={() => navigate(`/listing/${item.pubkey}/${item.identifier}`)}
> >
<CardContent className="p-0"> {/* Stretched link: real link semantics, context menu, middle-click and
keyboard activation. Interactive controls above re-enable pointer events. */}
<Link
to={listingPath}
aria-label={t('marketUi.card.viewTitle', { title: item.title })}
className="absolute inset-0 z-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
/>
<CardContent className="pointer-events-none relative z-10 p-0">
{/* Image */} {/* Image */}
<div className="relative aspect-square overflow-hidden rounded-t-lg"> <div className="relative aspect-square overflow-hidden rounded-t-lg">
{primaryImage ? ( {primaryImage ? (
<img <ImageWithFallback
src={primaryImage} src={primaryImage}
alt={item.title} alt={item.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="h-full w-full object-cover transition-transform group-hover:scale-105"
fallback={<span className="text-6xl">{isWTB ? '🔍' : '🛹'}</span>}
/> />
) : ( ) : (
<div <div
@ -198,7 +213,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
{/* More options menu — owners manage their listing (mark sold / {/* More options menu — owners manage their listing (mark sold /
relist / delete), non-owners can report, admins can take down. */} relist / delete), non-owners can report, admins can take down. */}
{user && ( {user && (
<div className="absolute bottom-2 right-2 z-20"> <div className="pointer-events-auto absolute bottom-2 right-2 z-20">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -358,7 +373,8 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Badge <Badge
variant="outline" variant="outline"
className={`text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`} onClick={() => navigate(listingPath)}
className={`pointer-events-auto cursor-pointer text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
> >
{trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />} {trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />}
{trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />} {trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />}
@ -378,7 +394,10 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1"> <div
className="pointer-events-auto flex cursor-pointer items-center gap-1"
onClick={() => navigate(listingPath)}
>
{sellerRating.rating > 0 ? ( {sellerRating.rating > 0 ? (
<span className="text-sm">{sellerRating.display}</span> <span className="text-sm">{sellerRating.display}</span>
) : ( ) : (
@ -403,14 +422,14 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className={`w-full mt-3 gap-2 ${ className={`pointer-events-auto mt-3 w-full gap-2 ${
isWTB isWTB
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10' ? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10' : 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
}`} }`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/messages/${npub}`); contactSeller();
}} }}
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />

View file

@ -33,6 +33,7 @@ export function ImageWithFallback({
onError, onError,
loading = 'lazy', loading = 'lazy',
decoding = 'async', decoding = 'async',
referrerPolicy = 'no-referrer',
...props ...props
}: ImageWithFallbackProps) { }: ImageWithFallbackProps) {
const candidates = useMemo(() => imageCandidates(src), [src]); const candidates = useMemo(() => imageCandidates(src), [src]);
@ -67,6 +68,7 @@ export function ImageWithFallback({
className={className} className={className}
loading={loading} loading={loading}
decoding={decoding} decoding={decoding}
referrerPolicy={referrerPolicy}
onError={(event) => { onError={(event) => {
if (index + 1 < candidates.length) { if (index + 1 < candidates.length) {
setIndex(index + 1); setIndex(index + 1);

View file

@ -10,7 +10,8 @@ import { Skeleton } from '@/components/ui/skeleton';
/** The Lab page's own ServiceType union (kept in sync with LabPage). */ /** The Lab page's own ServiceType union (kept in sync with LabPage). */
type ServiceType = type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building' | 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering' | 'clothing'; | 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering' | 'clothing'
| 'transport';
/** Translate the Lab page's ServiceType into a crew focus scene (or null). */ /** Translate the Lab page's ServiceType into a crew focus scene (or null). */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> | null { function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> | null {
@ -20,6 +21,7 @@ function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed
case 'judging': case 'judging':
case 'organizing': case 'organizing':
case 'barbering': case 'barbering':
case 'transport':
return null; return null;
case 'art': case 'art':
case 'graffiti': case 'graffiti':

View file

@ -23,7 +23,7 @@ export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProp
const { data: allProfiles } = useLabProfiles(200); const { data: allProfiles } = useLabProfiles(200);
const typeLabel = (type: string) => { const typeLabel = (type: string) => {
const known = ['coach', 'filmer', 'editor', 'photographer', 'builder', 'artist', 'graffiti', 'tattoo', 'trainer', 'musician', 'judge', 'barber', 'organizer']; const known = ['coach', 'filmer', 'editor', 'photographer', 'builder', 'artist', 'graffiti', 'tattoo', 'trainer', 'musician', 'judge', 'barber', 'organizer', 'transport'];
return known.includes(type) ? t(`labUi.labLink.${type}`) : t('labUi.labLink.lab'); return known.includes(type) ? t(`labUi.labLink.${type}`) : t('labUi.labLink.lab');
}; };

View file

@ -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)}

View file

@ -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,10 +51,14 @@ import {
MessagesSquare, MessagesSquare,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronDown,
ArrowLeft,
Search, Search,
SmilePlus,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { SKATING_STATUSES, useUserStatus } from '@/hooks/useUserStatus';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
@ -111,6 +114,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 +186,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 +223,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 +237,17 @@ 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"> </Link>
<CheckCircle2 className="h-3 w-3 inline mr-1" /> <DropdownMenuTrigger asChild>
{t('chromeUi.inBadge')} <button
</span> type="button"
{/* Green online indicator dot */} aria-label={t('chromeUi.menu')}
<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" /> 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}
@ -432,7 +428,7 @@ function MobileNav({
)} )}
</span> </span>
<span className={cn( <span className={cn(
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all', 'block w-full truncate text-center text-[10px] xs:text-[11px] leading-tight tracking-wide uppercase transition-all',
active ? 'font-extrabold' : 'font-semibold', active ? 'font-extrabold' : 'font-semibold',
)}> )}>
{tab.labelKey ? t(tab.labelKey) : tab.label} {tab.labelKey ? t(tab.labelKey) : tab.label}
@ -457,73 +453,84 @@ 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>
); );
} }
/**
* Compact desktop status control. The old outlined pill + permanent ping read
* like an alert; this shows the current status as a quiet chip and falls back
* to a neutral "Status" affordance when none is set.
*/
function DesktopStatusButton({ onOpen }: { onOpen: () => void }) {
const { user } = useCurrentUser();
const { t } = useTranslation();
const { data: status } = useUserStatus(user?.pubkey);
const preset = status?.statusId
? SKATING_STATUSES.find((item) => item.id === status.statusId)
: null;
const label = preset ? t(preset.labelKey) : status?.status;
const emoji = status?.emoji || preset?.emoji;
const title = status && label
? `${label} — ${t('chromeUi.setYourStatus')}`
: t('chromeUi.setYourStatus');
return (
<Button
variant="ghost"
onClick={onOpen}
title={title}
aria-label={title}
className={cn(
'h-9 w-9 shrink-0 rounded-full border border-white/10 p-0 text-gray-300',
'hover:border-green-500/30 hover:bg-green-500/10 hover:text-green-300',
status && 'border-green-500/20 bg-green-500/5 text-green-200',
)}
>
{emoji ? (
<span className="text-base leading-none" aria-hidden="true">{emoji}</span>
) : (
<SmilePlus className="h-4 w-4 text-green-400/80" />
)}
</Button>
);
}
export function Layout({ children, noPadding }: LayoutProps) { 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';
// The floating assistant must not cover form controls on long-form pages.
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);
@ -583,7 +590,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,
@ -618,9 +624,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
<div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8"> <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/logo.png"
alt="SkateSpots" alt="SkateSpots"
className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow" className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
/> />
@ -781,20 +791,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Link> </Link>
)} )}
{/* Status Quick Access - Desktop */} {/* Status Quick Access - Desktop */}
{user && ( {user && <DesktopStatusButton onOpen={() => setShowStatusDialog(true)} />}
<Button
variant="outline"
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
onClick={() => setShowStatusDialog(true)}
title={t('chromeUi.setYourStatus')}
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-sm font-bold">{t('chromeUi.status')}</span>
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -837,13 +834,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/logo.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>
@ -1181,7 +1199,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 */}
@ -1229,7 +1249,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"
/> />
@ -1246,10 +1266,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}
@ -1262,12 +1280,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Suspense> </Suspense>
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */} {/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
{!hideSasquatch && (
<Suspense fallback={null}> <Suspense fallback={null}>
<LazySasquatchGuide <LazySasquatchGuide
message={currentMessage} message={currentMessage}
onDismiss={dismissCurrentMessage} onDismiss={dismissCurrentMessage}
/> />
</Suspense> </Suspense>
)}
{/* Set Status Dialog — lazy (on-demand modal) */} {/* Set Status Dialog — lazy (on-demand modal) */}
<Suspense fallback={null}> <Suspense fallback={null}>

View 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>
)}
</>
);
}

View file

@ -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

View file

@ -44,6 +44,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const listingRef = { pubkey: item.pubkey, identifier: item.identifier }; const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`; const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
const listingUrl = `https://skate-spots.org${listingPath}`;
const contactSeller = () => navigate(
`/messages/${nip19.npubEncode(item.pubkey)}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
);
const { data: trade } = useListingTrade(listingRef, user?.pubkey); const { data: trade } = useListingTrade(listingRef, user?.pubkey);
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey); const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation(); const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
@ -305,7 +310,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10' ? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10' : 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
}`} }`}
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)} onClick={contactSeller}
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
{isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')} {isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}

View file

@ -1,8 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ChevronUp } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
/** /**
* The site links that live in the desktop footer — surfaced on the map * The site links that live in the desktop footer — surfaced on the map
@ -14,8 +11,6 @@ import { cn } from '@/lib/utils';
*/ */
export function MapFooterStrip() { export function MapFooterStrip() {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false);
const links = [ const links = [
{ to: '/diy', label: t('nav.diy') }, { to: '/diy', label: t('nav.diy') },
{ to: '/newsletter', label: t('nav.newsletter') }, { to: '/newsletter', label: t('nav.newsletter') },
@ -37,32 +32,20 @@ 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>
</div> </div>
{/* Mobile — a tiny chip that expands to a compact row */} {/* Mobile — a slim always-visible strip, not a floating chip over the nav */}
<div className="pointer-events-auto relative mb-1.5 flex justify-center md:hidden"> <div className="pointer-events-auto flex w-full items-center justify-center gap-3 border-t border-white/10 bg-black/80 px-3 py-1 text-[10px] text-gray-400 backdrop-blur md:hidden">
{open && (
<div className="absolute bottom-full left-1/2 mb-1.5 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-3 overflow-x-auto whitespace-nowrap rounded-full border border-white/10 bg-black/85 px-3 py-1.5 text-[10px] text-gray-300 backdrop-blur scrollbar-hide">
{links.map((l) => ( {links.map((l) => (
<Link key={l.to} to={l.to} className="font-medium hover:text-[#ccff00]"> <Link key={l.to} to={l.to} className="font-semibold transition-colors hover:text-[#ccff00]">
{l.label} {l.label}
</Link> </Link>
))} ))}
</div> </div>
)}
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex min-h-0 items-center gap-1 rounded-full bg-black/45 px-2.5 py-[3px] text-[10px] font-semibold text-gray-400/90 backdrop-blur transition-colors hover:text-gray-200"
>
<ChevronUp className={cn('h-3 w-3 transition-transform', open && 'rotate-180')} />
{t('mapUi.mapFooter.links')}
</button>
</div>
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@/lib/nostrLogin';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { NSchema as n } from '@nostrify/nostrify'; import { NSchema as n } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';

View file

@ -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')}

View file

@ -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}

View 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>
);
}

View file

@ -22,6 +22,12 @@ import {
import { getLastLocation } from '@/lib/lastLocation'; import { getLastLocation } from '@/lib/lastLocation';
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache'; import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
import { createCachedTileLayer } from '@/lib/cachedTileLayer'; import { createCachedTileLayer } from '@/lib/cachedTileLayer';
import {
MAP_TILE_URL,
MAP_TILE_SUBDOMAINS,
MAP_TILE_CACHE_NAMESPACE,
MAP_TILE_ATTRIBUTION,
} from '@/lib/mapTiles';
import { calculateDistance } from '@/lib/sortOptions'; import { calculateDistance } from '@/lib/sortOptions';
import { import {
fetchRoute, fetchRoute,
@ -598,13 +604,13 @@ export function RouteDialog({
minZoom: 2, minZoom: 2,
}); });
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { createCachedTileLayer(MAP_TILE_URL, {
attribution: '', attribution: MAP_TILE_ATTRIBUTION,
subdomains: 'abc', subdomains: MAP_TILE_SUBDOMAINS,
maxZoom: 19, maxZoom: 19,
noWrap: true, noWrap: true,
className: 'skatespots-dark-tiles', className: 'skatespots-dark-tiles',
}).addTo(map); }, MAP_TILE_CACHE_NAMESPACE).addTo(map);
// Metric scale bar — genuinely useful on a route map (how long is that line?). // Metric scale bar — genuinely useful on a route map (how long is that line?).
L.control.scale({ imperial: false, position: 'topleft' }).addTo(map); L.control.scale({ imperial: false, position: 'topleft' }).addTo(map);

View 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;
}

Some files were not shown because too many files have changed in this diff Show more