From ddd978ce287489498469809e72e992c6ed3ed565 Mon Sep 17 00:00:00 2001 From: Tobi Adeyemi Date: Wed, 13 May 2026 22:24:20 +0100 Subject: [PATCH] feat(nostr): implement nostr profile management - Securely store nsec in OS-level secure storage - Add identity chip to dashboard and new profile management route - Implement deterministic identicons via jdenticon and bech32 decoding - Add nsec backup/reveal flow with confirmation in Settings --- package.json | 2 + pnpm-lock.yaml | 146 +++++--- src-tauri/Cargo.lock | 312 ++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/nostr.rs | 328 +++++++++++++++++ src-tauri/src/commands/wallet.rs | 5 +- src-tauri/src/lib.rs | 16 + src-tauri/src/nostr/mod.rs | 8 + src-tauri/src/nostr/relays.rs | 7 + src/App.css | 58 +++ src/App.tsx | 5 +- src/components/IdentityChip.tsx | 63 ++++ src/components/NpubIdenticon.tsx | 33 ++ src/context/NostrIdentityContext.tsx | 166 +++++++++ src/router.tsx | 8 + src/routes/dashboard-route.tsx | 3 +- src/routes/profile-route.tsx | 513 +++++++++++++++++++++++++++ src/routes/settings-route.tsx | 141 ++++++++ src/utils/npub.test.ts | 30 ++ src/utils/npub.ts | 21 ++ 21 files changed, 1819 insertions(+), 48 deletions(-) create mode 100644 src-tauri/src/commands/nostr.rs create mode 100644 src-tauri/src/nostr/mod.rs create mode 100644 src-tauri/src/nostr/relays.rs create mode 100644 src/components/IdentityChip.tsx create mode 100644 src/components/NpubIdenticon.tsx create mode 100644 src/context/NostrIdentityContext.tsx create mode 100644 src/routes/profile-route.tsx create mode 100644 src/utils/npub.test.ts create mode 100644 src/utils/npub.ts diff --git a/package.json b/package.json index 4c909c3..a7329b5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "@tauri-apps/plugin-http": "^2", "@tauri-apps/plugin-opener": "^2", "@walletconnect/sign-client": "^2.23.9", + "bech32": "^2.0.0", + "jdenticon": "^3.3.0", "qr-scanner": "^1.4.2", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf4c688..150f2f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@walletconnect/sign-client': specifier: ^2.23.9 version: 2.23.9(typescript@5.8.3)(zod@4.3.6) + bech32: + specifier: ^2.0.0 + version: 2.0.0 + jdenticon: + specifier: ^3.3.0 + version: 3.3.0 qr-scanner: specifier: ^1.4.2 version: 1.4.2 @@ -50,7 +56,7 @@ importers: version: 9.39.4 '@tailwindcss/vite': specifier: ^4.2.1 - version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 4.2.1(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1)) '@tauri-apps/cli': specifier: ^2 version: 2.10.1 @@ -62,7 +68,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 4.7.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1)) '@walletconnect/types': specifier: ^2.23.9 version: 2.23.9 @@ -89,10 +95,10 @@ importers: version: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) vite: specifier: ^7.0.4 - version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + version: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) vitest: specifier: ^3.0.0 - version: 3.2.4(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + version: 3.2.4(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) packages: @@ -491,89 +497,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -877,66 +899,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1033,24 +1068,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -1135,30 +1174,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.1': resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.1': resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.1': resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.1': resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} @@ -1213,6 +1257,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + '@types/offscreencanvas@2019.7.3': resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} @@ -1443,6 +1490,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bech32@2.0.0: + resolution: {integrity: sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==} + better-sqlite3@12.9.0: resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} @@ -1486,6 +1536,9 @@ packages: caniuse-lite@1.0.30001777: resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + canvas-renderer@2.2.1: + resolution: {integrity: sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1733,9 +1786,6 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -1809,6 +1859,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jdenticon@3.3.0: + resolution: {integrity: sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg==} + engines: {node: '>=6.4.0'} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1887,24 +1942,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -2194,9 +2253,6 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2353,11 +2409,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -2394,6 +2445,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + unstorage@1.17.5: resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} peerDependencies: @@ -3417,12 +3471,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) '@tanstack/history@1.161.4': {} @@ -3545,6 +3599,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + '@types/offscreencanvas@2019.7.3': {} '@types/react-dom@19.2.3(@types/react@19.2.14)': @@ -3646,7 +3704,7 @@ snapshots: '@typescript-eslint/types': 8.57.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -3654,7 +3712,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color @@ -3666,13 +3724,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -4000,6 +4058,8 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bech32@2.0.0: {} + better-sqlite3@12.9.0: dependencies: bindings: 1.5.0 @@ -4051,6 +4111,10 @@ snapshots: caniuse-lite@1.0.30001777: {} + canvas-renderer@2.2.1: + dependencies: + '@types/node': 25.6.2 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -4313,11 +4377,6 @@ snapshots: get-nonce@1.0.1: {} - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - optional: true - github-from-package@0.0.0: optional: true @@ -4383,6 +4442,10 @@ snapshots: isexe@2.0.0: {} + jdenticon@3.3.0: + dependencies: + canvas-renderer: 2.2.1 + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -4738,9 +4801,6 @@ snapshots: resolve-from@4.0.0: {} - resolve-pkg-maps@1.0.0: - optional: true - rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -4927,14 +4987,6 @@ snapshots: tslib@2.8.1: {} - tsx@4.21.0: - dependencies: - esbuild: 0.27.3 - get-tsconfig: 4.14.0 - optionalDependencies: - fsevents: 2.3.3 - optional: true - tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -4969,6 +5021,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@7.19.2: {} + unstorage@1.17.5(idb-keyval@6.2.2): dependencies: anymatch: 3.1.3 @@ -5027,13 +5081,13 @@ snapshots: - '@types/react' - '@types/react-dom' - vite-node@3.2.4(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): + vite-node@3.2.4(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) transitivePeerDependencies: - '@types/node' - jiti @@ -5048,7 +5102,7 @@ snapshots: - tsx - yaml - vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): + vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -5057,16 +5111,16 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.6.2 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 - tsx: 4.21.0 - vitest@3.2.4(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): + vitest@3.2.4(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -5084,9 +5138,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) - vite-node: 3.2.4(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) + vite-node: 3.2.4(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.31.1) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.2 transitivePeerDependencies: - jiti - less diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 06a23d2..515a97e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.12" @@ -345,6 +355,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "atk" version = "0.18.2" @@ -377,6 +418,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -406,6 +453,7 @@ dependencies = [ "jni", "keyring", "ndk-context", + "nostr-sdk", "openssl", "rand 0.8.5", "reqwest 0.12.28", @@ -679,6 +727,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -825,6 +882,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -890,6 +956,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -902,6 +992,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "combine" version = "4.6.7" @@ -1076,6 +1177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1151,6 +1253,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -2588,6 +2696,28 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2941,6 +3071,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198" + [[package]] name = "lru-slab" version = "0.1.2" @@ -3151,6 +3287,12 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3173,6 +3315,72 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nostr" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d193102a62a22b61f9a61b9df54fb19ebab8c1763d088fbb9a6f0f57000fba2d" +dependencies = [ + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "instant", + "regex", + "scrypt", + "secp256k1 0.29.1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aafe85dc7c039c399796043b76009fa744c3a45ac073a023932f7b7d91b1e7" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fccd6f534c3b7c8bae575bb87947b5c388c4aaedbfba5cf8122f7278fb21ea77" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e928ba9ac2695fbe10b8aefda59f2abfeb23c10a0e86a0b3e0f6a27bb274a2" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-relay-pool", + "tokio", + "tracing", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3386,6 +3594,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -3522,6 +3736,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3534,6 +3759,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3810,6 +4045,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4573,6 +4819,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4648,6 +4903,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -6016,6 +6283,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6387,6 +6670,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typed-arena" version = "2.0.2" @@ -6502,6 +6804,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 20f9b62..7e9c262 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,6 +52,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } subtle = "2" hex = "0.4" uuid = { version = "1", features = ["v4", "serde"] } +nostr-sdk = { version = "0.42", default-features = false } # User-facing LendaSwap swap records live in a dedicated `lendaswap.db`. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0a93065..bcb948d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod coins; pub mod lendaswap; pub mod lightning; +pub mod nostr; pub mod pin; pub mod receive; pub mod send; diff --git a/src-tauri/src/commands/nostr.rs b/src-tauri/src/commands/nostr.rs new file mode 100644 index 0000000..3fa26d8 --- /dev/null +++ b/src-tauri/src/commands/nostr.rs @@ -0,0 +1,328 @@ +//! Nostr identity commands. +//! +//! The user's `nsec` is held in OS secure storage and never crosses the IPC +//! boundary — only the `npub` and broadcast outcomes are returned. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use nostr_sdk::{Client, Filter, Kind}; +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use crate::{ + nostr::{relays::DEFAULT_RELAYS, Keypair, Metadata, PublicKey, ToBech32, NSEC_KEY}, + secure_storage::SecureStorage, + AppError, NostrGenerateLock, +}; + +const FETCH_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Serialize)] +pub struct Identity { + pub npub: String, +} + +#[derive(Serialize)] +pub struct CurrentIdentity { + pub npub: Option, +} + +fn npub_from_keys(keys: &Keypair) -> Result { + keys.public_key() + .to_bech32() + .map_err(|e| AppError::Nostr(format!("encode npub: {e}"))) +} + +/// Pure: given the value currently in secure storage (or `None`), derive the npub. +fn npub_from_stored_nsec(stored: Option) -> Result, AppError> { + let Some(existing) = stored else { + return Ok(None); + }; + let keys = Keypair::parse(&existing) + .map_err(|e| AppError::Nostr(format!("decode stored nsec: {e}")))?; + Ok(Some(npub_from_keys(&keys)?)) +} + +/// Outcome of the generate-or-recover decision. +#[cfg_attr(test, derive(Debug))] +enum GenerateOutcome { + Existing { npub: String }, + Generated { npub: String, nsec: String }, +} + +/// Pure: given the value currently in secure storage, decide whether to return +/// the existing identity or generate a new one. Caller is responsible for +/// persisting the `nsec` from `Generated`. +fn compute_generate(stored: Option) -> Result { + if let Some(existing) = stored { + let keys = Keypair::parse(&existing) + .map_err(|e| AppError::Nostr(format!("decode stored nsec: {e}")))?; + return Ok(GenerateOutcome::Existing { + npub: npub_from_keys(&keys)?, + }); + } + + let keys = Keypair::generate(); + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| AppError::Nostr(format!("encode nsec: {e}")))?; + Ok(GenerateOutcome::Generated { + npub: npub_from_keys(&keys)?, + nsec, + }) +} + +/// Generate a new Nostr identity, or return the existing one if already present. +/// +/// The read-generate-write sequence is serialized by `NostrGenerateLock` so +/// concurrent callers can't both observe an empty NSEC_KEY and +/// generate competing keypairs. +#[tauri::command] +pub async fn nostr_generate_identity(app: AppHandle) -> Result { + let lock = app.state::(); + let _guard = lock.0.lock().await; + + let store = SecureStorage::get_instance(&app); + match compute_generate(store.get(NSEC_KEY)?)? { + GenerateOutcome::Existing { npub } => Ok(Identity { npub }), + GenerateOutcome::Generated { npub, nsec } => { + store.set(NSEC_KEY, &nsec)?; + Ok(Identity { npub }) + } + } +} + +/// Read the current Nostr identity. Returns `npub: None` when no identity exists. +#[tauri::command] +pub async fn nostr_get_identity(app: AppHandle) -> Result { + let store = SecureStorage::get_instance(&app); + Ok(CurrentIdentity { + npub: npub_from_stored_nsec(store.get(NSEC_KEY)?)?, + }) +} + +/// Reveal the user's `nsec` for backup. +#[tauri::command] +pub async fn nostr_reveal_nsec(app: AppHandle) -> Result { + let store = SecureStorage::get_instance(&app); + store + .get(NSEC_KEY)? + .ok_or_else(|| AppError::Nostr("no Nostr identity stored".into())) +} + +#[derive(Serialize)] +pub struct PublishResult { + pub event_id: String, + pub accepted_relays: Vec, +} + +/// Publish the user's `kind:0` profile metadata to the default relay set. +/// +/// Connects to all default relays and broadcasts in parallel via `nostr-sdk`. +/// Returns the event ID and the subset of relays that accepted the event. +/// Errors if no Nostr identity has been provisioned yet. +#[tauri::command] +pub async fn nostr_publish_metadata( + app: AppHandle, + metadata: Metadata, +) -> Result { + let store = SecureStorage::get_instance(&app); + let nsec = store + .get(NSEC_KEY)? + .ok_or_else(|| AppError::Nostr("no Nostr identity stored; generate one first".into()))?; + let keys = + Keypair::parse(&nsec).map_err(|e| AppError::Nostr(format!("decode stored nsec: {e}")))?; + + let client = Client::new(keys); + for url in DEFAULT_RELAYS { + client + .add_relay(*url) + .await + .map_err(|e| AppError::Nostr(format!("add relay {url}: {e}")))?; + } + client.connect().await; + + let output = client + .set_metadata(&metadata) + .await + .map_err(|e| AppError::Nostr(format!("publish metadata: {e}")))?; + + let event_id = output.val.to_hex(); + let accepted_relays: Vec = output.success.iter().map(|url| url.to_string()).collect(); + + Ok(PublishResult { + event_id, + accepted_relays, + }) +} + +#[derive(Serialize)] +pub struct FetchMetadataResult { + pub metadata: Option, + pub fetched_at: u64, +} + +/// Parse a `kind:0` event's content as `Metadata`. Returns `None` for malformed +/// JSON, with a warning logged so developers can distinguish "no profile +/// published" from "profile published but unparseable" via logcat. +fn parse_metadata_content(content: &str) -> Option { + match serde_json::from_str::(content) { + Ok(m) => Some(m), + Err(e) => { + tracing::warn!( + error = %e, + preview = %content.chars().take(120).collect::(), + "kind:0 event has unparseable content; treating as absent" + ); + None + } + } +} + +/// Fetch the latest `kind:0` metadata event for the given `npub` from the +/// default relay set. +/// +/// 5-second timeout: if no event is returned in that window, `metadata` is +/// `None`. No caching — every call hits the relays. +#[tauri::command] +pub async fn nostr_fetch_metadata(npub: String) -> Result { + let pubkey = + PublicKey::parse(&npub).map_err(|e| AppError::Nostr(format!("invalid npub: {e}")))?; + + let client = Client::default(); + for url in DEFAULT_RELAYS { + client + .add_relay(*url) + .await + .map_err(|e| AppError::Nostr(format!("add relay {url}: {e}")))?; + } + client.connect().await; + + let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1); + + let events = client + .fetch_events(filter, FETCH_TIMEOUT) + .await + .map_err(|e| AppError::Nostr(format!("fetch events: {e}")))?; + + let metadata = events + .into_iter() + .next() + .and_then(|ev| parse_metadata_content(&ev.content)); + + let fetched_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Ok(FetchMetadataResult { + metadata, + fetched_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn npub_from_stored_nsec_none_returns_none() { + let result = npub_from_stored_nsec(None).expect("should not error"); + assert!(result.is_none()); + } + + #[test] + fn npub_from_stored_nsec_some_valid_returns_matching_npub() { + let keys = Keypair::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let expected_npub = keys.public_key().to_bech32().unwrap(); + + let result = npub_from_stored_nsec(Some(nsec)).expect("should not error"); + assert_eq!(result, Some(expected_npub)); + } + + #[test] + fn npub_from_stored_nsec_some_invalid_returns_error() { + let err = npub_from_stored_nsec(Some("not-a-real-nsec".into())) + .expect_err("malformed nsec must error"); + assert!(matches!(err, AppError::Nostr(_))); + } + + #[test] + fn compute_generate_none_produces_consistent_keypair() { + let outcome = compute_generate(None).expect("should generate"); + let (npub, nsec) = match outcome { + GenerateOutcome::Generated { npub, nsec } => (npub, nsec), + GenerateOutcome::Existing { .. } => panic!("expected Generated, got Existing"), + }; + + // The generated nsec round-trips back to the generated npub. + let recovered = Keypair::parse(&nsec).unwrap(); + assert_eq!(recovered.public_key().to_bech32().unwrap(), npub); + } + + #[test] + fn compute_generate_existing_returns_matching_npub() { + let keys = Keypair::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let expected_npub = keys.public_key().to_bech32().unwrap(); + + let outcome = compute_generate(Some(nsec)).expect("should recover"); + match outcome { + GenerateOutcome::Existing { npub } => assert_eq!(npub, expected_npub), + GenerateOutcome::Generated { .. } => panic!("expected Existing, got Generated"), + } + } + + #[test] + fn compute_generate_is_idempotent() { + // First call with empty storage generates a new identity. + let first = compute_generate(None).expect("first should generate"); + let (first_npub, persisted_nsec) = match first { + GenerateOutcome::Generated { npub, nsec } => (npub, nsec), + GenerateOutcome::Existing { .. } => panic!("first call must generate"), + }; + + // Second call sees the persisted nsec and returns the same npub. + let second = compute_generate(Some(persisted_nsec)).expect("second should recover"); + match second { + GenerateOutcome::Existing { npub } => assert_eq!(npub, first_npub), + GenerateOutcome::Generated { .. } => panic!("second call must NOT regenerate"), + } + } + + #[test] + fn compute_generate_invalid_storage_returns_error() { + let err = + compute_generate(Some("garbage".into())).expect_err("invalid stored nsec must error"); + assert!(matches!(err, AppError::Nostr(_))); + } + + #[test] + fn parse_metadata_content_accepts_valid_json() { + let parsed = parse_metadata_content(r#"{"name":"alice","about":"hi"}"#) + .expect("valid kind:0 content should parse"); + assert_eq!(parsed.name.as_deref(), Some("alice")); + assert_eq!(parsed.about.as_deref(), Some("hi")); + } + + #[test] + fn parse_metadata_content_accepts_empty_object() { + // Every Metadata field is optional, so `{}` is a valid (empty) profile. + assert!(parse_metadata_content("{}").is_some()); + } + + #[test] + fn parse_metadata_content_drops_malformed_json() { + // Truncated JSON: no chance of a valid Metadata. + assert!(parse_metadata_content("{not json").is_none()); + } + + #[test] + fn parse_metadata_content_drops_wrong_shape() { + // Valid JSON, wrong shape — Metadata requires an object. + assert!(parse_metadata_content("[]").is_none()); + assert!(parse_metadata_content("\"just a string\"").is_none()); + } +} diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index 3a545e0..e7a1359 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -446,9 +446,12 @@ pub async fn delete_wallet(app: tauri::AppHandle) -> Result<(), AppError> { // it early ensures the app won't see a "wallet exists" state if later steps fail. let _ = remove_if_exists(&wallet_path(&app)?).await; - // 3. Delete mnemonic from secure storage. + // 3. Delete mnemonic and Nostr identity from secure storage. WIPE means a + // clean slate — leaving the nsec behind would resurrect the previous + // npub on re-onboarding. let store = secure_storage::SecureStorage::get_instance(&app); let _ = store.delete(MNEMONIC_KEY); + let _ = store.delete(crate::nostr::NSEC_KEY); // 4. Best-effort cleanup of remaining files — don't bail on individual failures. // Includes lendaswap.db so a subsequent wallet restore doesn't hit diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f0d29e9..dbc786d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod ark; mod commands; mod lendaswap; +mod nostr; mod secure_storage; mod wallet; @@ -61,6 +62,12 @@ pub(crate) struct GlobalWalletState(pub(crate) RwLock>); /// Guards wallet creation so only one can run at a time. pub(crate) struct WalletCreationLock(pub(crate) tokio::sync::Mutex<()>); +/// Guards the read-generate-write sequence in `nostr_generate_identity`. +/// Without this, two concurrent callers can both observe an empty NSEC_KEY, +/// generate different keypairs, and race on `store.set` — leaving the caller +/// holding an `npub` that no longer matches the stored `nsec`. +pub(crate) struct NostrGenerateLock(pub(crate) tokio::sync::Mutex<()>); + /// Interval for background onchain wallet sync. pub(crate) const ONCHAIN_SYNC_INTERVAL: Duration = Duration::from_secs(60); @@ -78,6 +85,8 @@ pub(crate) enum AppError { Wallet(String), #[error("Secure storage error: {0}")] SecureStorage(#[from] secure_storage::Error), + #[error("Nostr error: {0}")] + Nostr(String), } impl Serialize for AppError { @@ -244,6 +253,7 @@ pub fn run() { .manage(SettingsLock(RwLock::new(()))) .manage(GlobalWalletState(RwLock::new(None))) .manage(WalletCreationLock(tokio::sync::Mutex::new(()))) + .manage(NostrGenerateLock(tokio::sync::Mutex::new(()))) .manage(commands::receive::ReceiveSubscriptionState( tokio::sync::Mutex::new(None), )) @@ -339,6 +349,12 @@ pub fn run() { commands::share::share_text, // Round schedule commands::wallet::get_round_schedule, + // Nostr identity + commands::nostr::nostr_generate_identity, + commands::nostr::nostr_get_identity, + commands::nostr::nostr_reveal_nsec, + commands::nostr::nostr_publish_metadata, + commands::nostr::nostr_fetch_metadata, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/nostr/mod.rs b/src-tauri/src/nostr/mod.rs new file mode 100644 index 0000000..7974465 --- /dev/null +++ b/src-tauri/src/nostr/mod.rs @@ -0,0 +1,8 @@ +#![allow(unused_imports)] + +pub mod relays; + +pub use nostr_sdk::{Keys as Keypair, Metadata, PublicKey, ToBech32}; + +/// Secure-storage key for the user's Nostr private key (bech32 `nsec...`). +pub const NSEC_KEY: &str = "nostr_nsec"; diff --git a/src-tauri/src/nostr/relays.rs b/src-tauri/src/nostr/relays.rs new file mode 100644 index 0000000..d9fe2bb --- /dev/null +++ b/src-tauri/src/nostr/relays.rs @@ -0,0 +1,7 @@ +/// Default Nostr relay set. Hardcoded for v1 (no user-editable relay list). +pub const DEFAULT_RELAYS: &[&str] = &[ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + "wss://relay.nostr.band", +]; diff --git a/src/App.css b/src/App.css index 1433790..553cc0d 100644 --- a/src/App.css +++ b/src/App.css @@ -303,6 +303,64 @@ background-repeat: repeat-x; } +/* Identity-card surface — dot grid with a single lime glow at the top, so it + reads as a printed credential being "issued" outward. Sibling of + .swap-surface (machine glass) and .invoice-surface (claim ticket). */ +.identity-surface { + position: relative; + background: + radial-gradient( + ellipse 110% 55% at 50% -5%, + var(--color-accent-bg) 0%, + transparent 60% + ), + var(--color-bg-card); + overflow: hidden; +} +.identity-surface::before { + content: ''; + position: absolute; + inset: 0; + background-image: radial-gradient( + circle at 1px 1px, + var(--color-text-faint) 0.7px, + transparent 0 + ); + background-size: 16px 16px; + opacity: 0.35; + pointer-events: none; +} + +/* Corner brackets — passport / credential security marks. Pin all four to an + absolutely-positioned ancestor. */ +.id-corner { + position: absolute; + width: 14px; + height: 14px; + pointer-events: none; +} +.id-corner-tl { top: 12px; left: 12px; border-top: 1.5px solid var(--color-text-muted); border-left: 1.5px solid var(--color-text-muted); } +.id-corner-tr { top: 12px; right: 12px; border-top: 1.5px solid var(--color-text-muted); border-right: 1.5px solid var(--color-text-muted); } +.id-corner-bl { bottom: 12px; left: 12px; border-bottom: 1.5px solid var(--color-text-muted); border-left: 1.5px solid var(--color-text-muted); } +.id-corner-br { bottom: 12px; right: 12px; border-bottom: 1.5px solid var(--color-text-muted); border-right: 1.5px solid var(--color-text-muted); } + +/* Hairline accent ring offset from the identicon for a "security feature" + feel. The outer ring picks up the page background so it reads as a + floating mark, not a stroke. */ +.identicon-ring { + position: relative; + isolation: isolate; +} +.identicon-ring::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 9999px; + border: 1px solid var(--color-accent); + opacity: 0.5; + pointer-events: none; +} + /* Hide scrollbar for horizontal scroll containers */ .scrollbar-none { -ms-overflow-style: none; diff --git a/src/App.tsx b/src/App.tsx index ed7cea1..d4f35a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { ThemeProvider, useTheme } from "./context/ThemeContext"; import { PinLockProvider } from "./context/PinLockContext"; import { FiatProvider } from "./context/FiatContext"; import { WalletConnectProvider } from "./context/WalletConnectContext"; +import { NostrIdentityProvider } from "./context/NostrIdentityContext"; import "./App.css"; function AppInner() { @@ -30,7 +31,9 @@ function AppInner() { - + + + diff --git a/src/components/IdentityChip.tsx b/src/components/IdentityChip.tsx new file mode 100644 index 0000000..59ca980 --- /dev/null +++ b/src/components/IdentityChip.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { NpubIdenticon } from "./NpubIdenticon"; +import { useNostrIdentity } from "../context/NostrIdentityContext"; + +const AVATAR_PX = 28; + +function shortNpub(npub: string): string { + if (npub.length <= 14) return npub; + return `${npub.slice(0, 8)}…${npub.slice(-4)}`; +} + +export function IdentityChip() { + const { npub, status, metadata } = useNostrIdentity(); + const [failedPictureUrl, setFailedPictureUrl] = useState(null); + const picture = metadata?.picture?.trim(); + + if (status === "loading" || !npub) { + return ( +
+
+
+
+ ); + } + + const showPicture = picture && failedPictureUrl !== picture; + const label = + metadata?.display_name?.trim() || + metadata?.name?.trim() || + shortNpub(npub); + + return ( + + + + {showPicture && ( + setFailedPictureUrl(picture ?? null)} + /> + )} + + + {label} + + + ); +} diff --git a/src/components/NpubIdenticon.tsx b/src/components/NpubIdenticon.tsx new file mode 100644 index 0000000..39d4b81 --- /dev/null +++ b/src/components/NpubIdenticon.tsx @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import { toSvg } from "jdenticon"; +import { npubToHex } from "../utils/npub"; + +interface NpubIdenticonProps { + npub: string; + size: number; +} + +/** + * Deterministic SVG identicon derived from an npub. The bech32 is decoded to + * its hex pubkey before hashing so the output matches other Nostr clients that + * identicon off the same key. + */ +export function NpubIdenticon({ npub, size }: NpubIdenticonProps) { + const svg = useMemo(() => { + const hex = npubToHex(npub); + return toSvg(hex ?? npub, size); + }, [npub, size]); + + return ( + + ); +} diff --git a/src/context/NostrIdentityContext.tsx b/src/context/NostrIdentityContext.tsx new file mode 100644 index 0000000..0f665c0 --- /dev/null +++ b/src/context/NostrIdentityContext.tsx @@ -0,0 +1,166 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; + +type Status = "loading" | "ready" | "error"; +type MetadataStatus = "idle" | "loading" | "ready" | "error"; + +export interface NostrMetadata { + name?: string; + display_name?: string; + about?: string; + picture?: string; + lud16?: string; +} + +interface FetchMetadataResult { + metadata: NostrMetadata | null; + fetched_at: number; +} + +interface NostrIdentityValue { + npub: string | null; + status: Status; + metadata: NostrMetadata | null; + metadataFetchedAt: number | null; + metadataStatus: MetadataStatus; + refreshMetadata: () => Promise; + setMetadata: (m: NostrMetadata | null) => void; +} + +const NostrIdentityContext = createContext({ + npub: null, + status: "loading", + metadata: null, + metadataFetchedAt: null, + metadataStatus: "idle", + refreshMetadata: async () => {}, + setMetadata: () => {}, +}); + +export function useNostrIdentity() { + return useContext(NostrIdentityContext); +} + +export function NostrIdentityProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [npub, setNpub] = useState(null); + const [status, setStatus] = useState("loading"); + const [metadata, setMetadataState] = useState(null); + const [metadataFetchedAt, setMetadataFetchedAt] = useState(null); + const [metadataStatus, setMetadataStatus] = useState("idle"); + const cancelledRef = useRef(false); + + useEffect(() => { + cancelledRef.current = false; + (async () => { + try { + const current = await invoke<{ npub: string | null }>( + "nostr_get_identity", + ); + if (cancelledRef.current) return; + if (current.npub) { + setNpub(current.npub); + setStatus("ready"); + return; + } + const created = await invoke<{ npub: string }>( + "nostr_generate_identity", + ); + if (cancelledRef.current) return; + setNpub(created.npub); + setStatus("ready"); + } catch (e) { + if (cancelledRef.current) return; + const message = + typeof e === "string" ? e : "Failed to set up Nostr identity"; + setStatus("error"); + toast.error(message); + } + })(); + return () => { + cancelledRef.current = true; + }; + }, []); + + // Auto-fetch metadata once identity is ready + useEffect(() => { + if (!npub) return; + let cancelled = false; + (async () => { + try { + const res = await invoke("nostr_fetch_metadata", { + npub, + }); + if (cancelled) return; + setMetadataState(res.metadata); + setMetadataFetchedAt(res.fetched_at); + setMetadataStatus("ready"); + } catch { + if (cancelled) return; + setMetadataStatus("error"); + } + })(); + return () => { + cancelled = true; + }; + }, [npub]); + + const refreshMetadata = useCallback(async () => { + if (!npub) return; + try { + const res = await invoke("nostr_fetch_metadata", { + npub, + }); + setMetadataState(res.metadata); + setMetadataFetchedAt(res.fetched_at); + setMetadataStatus("ready"); + } catch { + setMetadataStatus("error"); + } + }, [npub]); + + const setMetadata = useCallback((m: NostrMetadata | null) => { + setMetadataState(m); + setMetadataFetchedAt(Math.floor(Date.now() / 1000)); + setMetadataStatus("ready"); + }, []); + + const value = useMemo( + () => ({ + npub, + status, + metadata, + metadataFetchedAt, + metadataStatus, + refreshMetadata, + setMetadata, + }), + [ + npub, + status, + metadata, + metadataFetchedAt, + metadataStatus, + refreshMetadata, + setMetadata, + ], + ); + + return ( + + {children} + + ); +} diff --git a/src/router.tsx b/src/router.tsx index 889b0ab..d06312e 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -10,6 +10,7 @@ import { SwapRoute } from "./routes/swap-route"; import { SwapCheckoutRoute } from "./routes/swap-checkout-route"; import { SwapHistoryRoute } from "./routes/swap-history-route"; import { RecoverLnRoute } from "./routes/recover-ln-route"; +import { ProfileRoute } from "./routes/profile-route"; import { AppLayout } from "./components/AppLayout"; const rootRoute = createRootRoute(); @@ -94,6 +95,12 @@ const recoverLnRoute = createRoute({ component: RecoverLnRoute, }); +const profileRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/profile", + component: ProfileRoute, +}); + const routeTree = rootRoute.addChildren([ bootRoute, onboardingRoute, @@ -105,6 +112,7 @@ const routeTree = rootRoute.addChildren([ swapCheckoutRoute, swapHistoryRoute, recoverLnRoute, + profileRoute, settingsRoute, ]), ]); diff --git a/src/routes/dashboard-route.tsx b/src/routes/dashboard-route.tsx index 54dcecb..a14c1be 100644 --- a/src/routes/dashboard-route.tsx +++ b/src/routes/dashboard-route.tsx @@ -9,6 +9,7 @@ import { useSatsToFiat } from '../context/FiatContext'; import { formatSats } from '../utils/format'; import { TransactionRow } from '../components/TransactionRow'; import { NextRoundCountdown } from '../components/NextRoundCountdown'; +import { IdentityChip } from '../components/IdentityChip'; interface SettleResult { settled: boolean; @@ -42,7 +43,7 @@ export function DashboardRoute() { > {/* Header */}
-

Avark

+
+
+

+ Identity +

+

+ kind:0 · nostr +

+
+
+ + {/* Identity card — full-bleed credential surface */} +
+ + + + + +
+
+
+ {npub ? ( + + ) : ( +
+ )} + {showPicture && ( + setFailedPictureUrl(picture ?? null)} + /> + )} +
+
+ + {/* Display name — hero */} + {isBootstrapping ? ( +
+ ) : ( +

+ {displayName} +

+ )} + + {/* About */} + {view.about?.trim() && ( +

+ {view.about.trim()} +

+ )} + + {/* Relay status pill */} + {!isBootstrapping && metadata && fetchedAt && ( +
+ + + + + + On relays · {timeAgo(fetchedAt)} + +
+ )} + + {/* Empty — never published */} + {!isBootstrapping && !metadata && metadataStatus === "ready" && ( + + Not yet published + + )} +
+ + {/* Perforation */} +
+ + {/* npub stub */} + +
+ + {/* Fetch error banner */} + {metadataStatus === "error" && !editMode && ( +
+
+

+ Couldn’t load profile from relays +

+ +
+
+ )} + + {/* Body */} +
+ {!editMode ? ( + <> + {/* Filled fields */} + {filledFields.length > 0 && ( + <> +

+ On file +

+
+ {filledFields.map((f) => ( + + ))} +
+ + )} + + {/* Missing fields hint */} + {filledFields.length > 0 && missingFields.length > 0 && ( + + )} + + {/* True empty state */} + {filledFields.length === 0 && metadataStatus === "ready" && ( +
+

+ Blank credential +

+

+ Your identity is created and broadcastable. Add a name and bio + to introduce yourself on the network. +

+
+ )} + + {/* Edit CTA */} + + + ) : ( + <> +

+ Edit credential +

+
+ setDraft((d) => ({ ...d, name: v }))} + placeholder="alice" + /> + setDraft((d) => ({ ...d, display_name: v }))} + placeholder="Alice" + /> + setDraft((d) => ({ ...d, about: v }))} + placeholder="A short bio" + multiline + /> + setDraft((d) => ({ ...d, picture: v }))} + placeholder="https://…" + mono + /> + setDraft((d) => ({ ...d, lud16: v }))} + placeholder="alice@walletofsatoshi.com" + mono + /> +
+ +
+ + +
+ + )} +
+ + ); +} + +function FieldRow({ + label, + value, + mono, + multiline, +}: { + label: string; + value: string; + mono?: boolean; + multiline?: boolean; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +function FormField({ + label, + value, + onChange, + placeholder, + multiline, + mono, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + multiline?: boolean; + mono?: boolean; +}) { + const inputClasses = `w-full rounded-xl theme-input px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-lime-300/50 ${ + mono ? "font-mono" : "" + }`; + return ( +