wip: new import account

This commit is contained in:
reya 2023-10-15 16:10:16 +07:00
parent 620e763380
commit cd3b9ada5a
13 changed files with 430 additions and 418 deletions

View File

@ -20,8 +20,8 @@
"dependencies": { "dependencies": {
"@evilmartians/harmony": "^1.1.0", "@evilmartians/harmony": "^1.1.0",
"@getalby/sdk": "^2.4.0", "@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^2.0.1", "@nostr-dev-kit/ndk": "^2.0.2",
"@nostr-dev-kit/ndk-cache-dexie": "^2.0.1", "@nostr-dev-kit/ndk-cache-dexie": "^2.0.2",
"@nostr-fetch/adapter-ndk": "^0.12.2", "@nostr-fetch/adapter-ndk": "^0.12.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
@ -56,6 +56,7 @@
"@tiptap/suggestion": "^2.1.12", "@tiptap/suggestion": "^2.1.12",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"destr": "^2.0.1", "destr": "^2.0.1",
"framer-motion": "^10.16.4",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"light-bolt11-decoder": "^3.0.0", "light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1", "lru-cache": "^10.0.1",
@ -75,6 +76,7 @@
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"reactflow": "^11.9.3", "reactflow": "^11.9.3",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sonner": "^1.0.3",
"tailwind-scrollbar": "^3.0.5", "tailwind-scrollbar": "^3.0.5",
"tauri-controls": "^0.2.0", "tauri-controls": "^0.2.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",

View File

@ -12,14 +12,14 @@ dependencies:
specifier: ^2.4.0 specifier: ^2.4.0
version: 2.4.0 version: 2.4.0
'@nostr-dev-kit/ndk': '@nostr-dev-kit/ndk':
specifier: ^2.0.1 specifier: ^2.0.2
version: 2.0.1(typescript@5.2.2) version: 2.0.2(typescript@5.2.2)
'@nostr-dev-kit/ndk-cache-dexie': '@nostr-dev-kit/ndk-cache-dexie':
specifier: ^2.0.1 specifier: ^2.0.2
version: 2.0.1(typescript@5.2.2) version: 2.0.2(typescript@5.2.2)
'@nostr-fetch/adapter-ndk': '@nostr-fetch/adapter-ndk':
specifier: ^0.12.2 specifier: ^0.12.2
version: 0.12.2(@nostr-dev-kit/ndk@2.0.1)(nostr-fetch@0.13.0) version: 0.12.2(@nostr-dev-kit/ndk@2.0.2)(nostr-fetch@0.13.0)
'@radix-ui/react-alert-dialog': '@radix-ui/react-alert-dialog':
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.13)(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0) version: 1.0.5(@types/react-dom@18.2.13)(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
@ -119,6 +119,9 @@ dependencies:
destr: destr:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
framer-motion:
specifier: ^10.16.4
version: 10.16.4(react-dom@18.2.0)(react@18.2.0)
html-to-text: html-to-text:
specifier: ^9.0.5 specifier: ^9.0.5
version: 9.0.5 version: 9.0.5
@ -176,6 +179,9 @@ dependencies:
remark-gfm: remark-gfm:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
sonner:
specifier: ^1.0.3
version: 1.0.3(react-dom@18.2.0)(react@18.2.0)
tailwind-scrollbar: tailwind-scrollbar:
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5(tailwindcss@3.3.3) version: 3.0.5(tailwindcss@3.3.3)
@ -551,6 +557,20 @@ packages:
'@babel/helper-validator-identifier': 7.22.20 '@babel/helper-validator-identifier': 7.22.20
to-fast-properties: 2.0.0 to-fast-properties: 2.0.0
/@emotion/is-prop-valid@0.8.8:
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
requiresBuild: true
dependencies:
'@emotion/memoize': 0.7.4
dev: false
optional: true
/@emotion/memoize@0.7.4:
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
requiresBuild: true
dev: false
optional: true
/@esbuild/android-arm64@0.18.20: /@esbuild/android-arm64@0.18.20:
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -920,10 +940,10 @@ packages:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.15.0 fastq: 1.15.0
/@nostr-dev-kit/ndk-cache-dexie@2.0.1(typescript@5.2.2): /@nostr-dev-kit/ndk-cache-dexie@2.0.2(typescript@5.2.2):
resolution: {integrity: sha512-O1ngV95yuZPhV0PB6JQAMHQkZvGtcW6qEY1jawvrZCfYLf2vdHWuzMN2rXYiSdrx6mMsnqB17bq5Lg3r8Coslw==} resolution: {integrity: sha512-v6dq82Gzw/AoDMtkjCeTg+gx9n6sX3xReaMpIUbKL5W+E7z1lqnR0RmYkeNxUhd7Tlg0FQ+Ywqq7nZs+UmGDEA==}
dependencies: dependencies:
'@nostr-dev-kit/ndk': 2.0.1(typescript@5.2.2) '@nostr-dev-kit/ndk': 2.0.2(typescript@5.2.2)
debug: 4.3.4 debug: 4.3.4
dexie: 3.2.4 dexie: 3.2.4
nostr-tools: 1.16.0(typescript@5.2.2) nostr-tools: 1.16.0(typescript@5.2.2)
@ -933,8 +953,8 @@ packages:
- typescript - typescript
dev: false dev: false
/@nostr-dev-kit/ndk@2.0.1(typescript@5.2.2): /@nostr-dev-kit/ndk@2.0.2(typescript@5.2.2):
resolution: {integrity: sha512-LZ7h4HL2B0Yek3Pr276OMaiVzr6WYXSWExZKn8bdpZ5lIzt5t1j4bi8kxwfUZti1Z/nIY7Hq7tIguty39YBs/g==} resolution: {integrity: sha512-EwaOJVS0FOCXlIffiVceKrK+QtbaRTG6QYdoQchMAe+ag2C3jl7nAoDTWlixv/WgJOFl4KPQkS8r0sEkGmXsjQ==}
dependencies: dependencies:
'@noble/hashes': 1.3.2 '@noble/hashes': 1.3.2
'@noble/secp256k1': 2.0.0 '@noble/secp256k1': 2.0.0
@ -952,13 +972,13 @@ packages:
- typescript - typescript
dev: false dev: false
/@nostr-fetch/adapter-ndk@0.12.2(@nostr-dev-kit/ndk@2.0.1)(nostr-fetch@0.13.0): /@nostr-fetch/adapter-ndk@0.12.2(@nostr-dev-kit/ndk@2.0.2)(nostr-fetch@0.13.0):
resolution: {integrity: sha512-+7EVuxS5DDZvNo6qbfFp7xRHwIyjyi36hYkiQFDjbQ4gX5LKo9RIPB1P+1XGkOSDFshypTbovZCaFunscJ/zhQ==} resolution: {integrity: sha512-+7EVuxS5DDZvNo6qbfFp7xRHwIyjyi36hYkiQFDjbQ4gX5LKo9RIPB1P+1XGkOSDFshypTbovZCaFunscJ/zhQ==}
peerDependencies: peerDependencies:
'@nostr-dev-kit/ndk': ^0.7.5 '@nostr-dev-kit/ndk': ^0.7.5
nostr-fetch: ^0.12.2 nostr-fetch: ^0.12.2
dependencies: dependencies:
'@nostr-dev-kit/ndk': 2.0.1(typescript@5.2.2) '@nostr-dev-kit/ndk': 2.0.2(typescript@5.2.2)
'@nostr-fetch/kernel': 0.12.2 '@nostr-fetch/kernel': 0.12.2
nostr-fetch: 0.13.0 nostr-fetch: 0.13.0
dev: false dev: false
@ -3082,7 +3102,7 @@ packages:
postcss: ^8.1.0 postcss: ^8.1.0
dependencies: dependencies:
browserslist: 4.22.1 browserslist: 4.22.1
caniuse-lite: 1.0.30001547 caniuse-lite: 1.0.30001549
fraction.js: 4.3.7 fraction.js: 4.3.7
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.0.0 picocolors: 1.0.0
@ -3134,7 +3154,7 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
dependencies: dependencies:
caniuse-lite: 1.0.30001547 caniuse-lite: 1.0.30001549
electron-to-chromium: 1.4.554 electron-to-chromium: 1.4.554
node-releases: 2.0.13 node-releases: 2.0.13
update-browserslist-db: 1.0.13(browserslist@4.22.1) update-browserslist-db: 1.0.13(browserslist@4.22.1)
@ -3163,8 +3183,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
/caniuse-lite@1.0.30001547: /caniuse-lite@1.0.30001549:
resolution: {integrity: sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==} resolution: {integrity: sha512-qRp48dPYSCYaP+KurZLhDYdVE+yEyht/3NlmcJgVQ2VMGt6JL36ndQ/7rgspdZsJuxDPFIo/OzBT2+GmIJ53BA==}
/case-anything@2.1.13: /case-anything@2.1.13:
resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==}
@ -3997,6 +4017,24 @@ packages:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
dev: true dev: true
/framer-motion@10.16.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-p9V9nGomS3m6/CALXqv6nFGMuFOxbWsmaOrdmhyQimMIlLl3LC7h7l86wge/Js/8cRu5ktutS/zlzgR7eBOtFA==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tslib: 2.6.2
optionalDependencies:
'@emotion/is-prop-valid': 0.8.8
dev: false
/fs.realpath@1.0.0: /fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -5464,7 +5502,7 @@ packages:
dependencies: dependencies:
lilconfig: 2.1.0 lilconfig: 2.1.0
postcss: 8.4.31 postcss: 8.4.31
yaml: 2.3.2 yaml: 2.3.3
/postcss-nested@6.0.1(postcss@8.4.31): /postcss-nested@6.0.1(postcss@8.4.31):
resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==}
@ -5801,7 +5839,7 @@ packages:
remark-parse: 10.0.2 remark-parse: 10.0.2
remark-rehype: 10.1.0 remark-rehype: 10.1.0
space-separated-tokens: 2.0.2 space-separated-tokens: 2.0.2
style-to-object: 0.4.2 style-to-object: 0.4.3
unified: 10.1.2 unified: 10.1.2
unist-util-visit: 4.1.2 unist-util-visit: 4.1.2
vfile: 5.3.7 vfile: 5.3.7
@ -6140,6 +6178,16 @@ packages:
is-fullwidth-code-point: 4.0.0 is-fullwidth-code-point: 4.0.0
dev: true dev: true
/sonner@1.0.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-hBoA2zKuYW3lUnpx4K0vAn8j77YuYiwvP9sLQfieNS2pd5FkT20sMyPTDJnl9S+5T27ZJbwQRPiujwvDBwhZQg==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/source-map-js@1.0.2: /source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -6230,8 +6278,8 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
dev: true dev: true
/style-to-object@0.4.2: /style-to-object@0.4.3:
resolution: {integrity: sha512-1JGpfPB3lo42ZX8cuPrheZbfQ6kqPPnPHlKMyeRYtfKD+0jG+QsXgXN57O/dvJlzlB2elI6dGmrPnl5VPQFPaA==} resolution: {integrity: sha512-RP9icVx0g3Pt0CyNiC2qvBkqMTHD5uBVC2XYcSr/ag8QWKApx/oXEh2ehMGSyzkjK0+ySkukMuO+mz+DNQq57Q==}
dependencies: dependencies:
inline-style-parser: 0.1.1 inline-style-parser: 0.1.1
dev: false dev: false
@ -6850,8 +6898,8 @@ packages:
engines: {node: '>= 14'} engines: {node: '>= 14'}
dev: true dev: true
/yaml@2.3.2: /yaml@2.3.3:
resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==} resolution: {integrity: sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
/yocto-queue@0.1.0: /yocto-queue@0.1.0:

30
src-tauri/Cargo.lock generated
View File

@ -1087,6 +1087,7 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3" checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3"
dependencies = [ dependencies = [
"powerfmt",
"serde", "serde",
] ]
@ -3445,6 +3446,12 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.17" version = "0.2.17"
@ -3681,14 +3688,14 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.10.0" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d119d7c7ca818f8a53c300863d4f87566aac09943aef5b355bb83969dae75d87" checksum = "aaac441002f822bc9705a681810a4dd2963094b9ca0ddc41cb963a4c189189ea"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-automata 0.4.1", "regex-automata 0.4.2",
"regex-syntax 0.8.1", "regex-syntax 0.8.2",
] ]
[[package]] [[package]]
@ -3702,13 +3709,13 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.1" version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465c6fc0621e4abc4187a2bda0937bfd4f722c2730b29562e19689ea796c9a4b" checksum = "5011c7e263a695dc8ca064cddb722af1be54e517a280b12a5356f98366899e5d"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-syntax 0.8.1", "regex-syntax 0.8.2",
] ]
[[package]] [[package]]
@ -3719,9 +3726,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56d84fdd47036b038fc80dd333d10b6aab10d5d31f4a366e20014def75328d33" checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
@ -5322,12 +5329,13 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.29" version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe" checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa 1.0.9", "itoa 1.0.9",
"powerfmt",
"serde", "serde",
"time-core", "time-core",
"time-macros", "time-macros",

View File

@ -3,8 +3,7 @@ import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom'; import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow'; import { ReactFlowProvider } from 'reactflow';
import { AuthCreateScreen } from '@app/auth/create'; import { CreateAccountScreen } from '@app/auth/create';
import { AuthImportScreen } from '@app/auth/import';
import { OnboardingScreen } from '@app/auth/onboarding'; import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats'; import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error'; import { ErrorScreen } from '@app/error';
@ -28,7 +27,7 @@ export default function App() {
const totalAccount = await db.checkAccount(); const totalAccount = await db.checkAccount();
const onboarding = localStorage.getItem('onboarding'); const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null; const step = onboarding ? JSON.parse(onboarding).state.step : null;
// redirect to welcome screen if none user exist // redirect to welcome screen if none user exist
if (totalAccount === 0) return redirect('/auth/welcome'); if (totalAccount === 0) return redirect('/auth/welcome');
@ -169,46 +168,16 @@ export default function App() {
}, },
}, },
{ {
path: 'import', path: 'create',
element: <AuthImportScreen />, element: <CreateAccountScreen />,
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { ImportStep1Screen } = await import('@app/auth/import/step-1');
return { Component: ImportStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { ImportStep2Screen } = await import('@app/auth/import/step-2');
return { Component: ImportStep2Screen };
},
},
],
}, },
{ {
path: 'create', path: 'import',
element: <AuthCreateScreen />, async lazy() {
errorElement: <ErrorScreen />, const { ImportAccountScreen } = await import('@app/auth/import');
children: [ return { Component: ImportAccountScreen };
{ },
path: '',
async lazy() {
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
return { Component: CreateStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { CreateStep2Screen } = await import('@app/auth/create/step-2');
return { Component: CreateStep2Screen };
},
},
],
}, },
{ {
path: 'onboarding', path: 'onboarding',

View File

@ -1,9 +1,5 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
export function AuthCreateScreen() { export function CreateAccountScreen() {
return ( return <Outlet />;
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
} }

View File

@ -16,7 +16,6 @@ export function CreateStep1Screen() {
const { db } = useStorage(); const { db } = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
const setPubkey = useOnboarding((state) => state.setPubkey); const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep); const setStep = useOnboarding((state) => state.setStep);
@ -29,6 +28,17 @@ export function CreateStep1Screen() {
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey); const nsec = nip19.nsecEncode(privkey);
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
const download = async () => { const download = async () => {
try { try {
const downloadPath = await downloadDir(); const downloadPath = await downloadDir();
@ -50,29 +60,22 @@ export function CreateStep1Screen() {
} }
}; };
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
const submit = async () => { const submit = async () => {
setLoading(true); try {
setLoading(true);
setPubkey(pubkey);
// update state // save privkey
setTempPrivkey(privkey); // only use if user close app and reopen it await db.secureSave(privkey, pubkey);
setPubkey(pubkey);
// save to database // save to database
await db.createAccount(npub, pubkey); await db.createAccount(npub, pubkey);
// redirect to next step // redirect to next step
navigate('/auth/create/step-2', { replace: true }); navigate('/auth/create/step-2', { replace: true });
} catch (e) {
await message(e, { title: 'Something went wrong!', type: 'error' });
}
}; };
useEffect(() => { useEffect(() => {
@ -81,70 +84,86 @@ export function CreateStep1Screen() {
}, []); }, []);
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="flex h-full w-full items-center justify-center">
<div className="mb-4 border-b border-white/10 pb-4"> <div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="mb-2 text-center text-2xl font-semibold text-white"> <div>
This is your new Nostr account <h1 className="mb-2 text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
</h1> This is your new Nostr account
<p className="mb-2 text-white/70"> </h1>
Your private key is your password. If you lose this key, you will lose access to <p className="mb-2 select-text text-neutral-600 dark:text-neutral-300">
your account! Copy it and keep it in a safe place. There is no way to reset your Your private key is your password. If you lose this key, you will lose access
private key. to your account! Copy it and keep it in a safe place.{' '}
</p> <span className="text-red-500">
<p className="text-white/70"> There is no way to reset your private key.
Public key is used for sharing with other people so that they can find you using </span>
the public key. </p>
</p> <p className="select-text text-neutral-600 dark:text-neutral-300">
</div> Public key is used for sharing with other people so that they can find you
<div className="flex flex-col gap-8"> using the public key.
</p>
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-3">
<span className="font-medium text-white">Private Key</span> <div className="flex flex-col gap-1">
<div className="relative"> <label
htmlFor="nsec"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<div className="relative">
<input
readOnly
name="nsec"
value={nsec.substring(0, 5) + '**************************************'}
className="relative h-12 w-full rounded-lg bg-neutral-200 py-1 pl-3.5 pr-11 text-neutral-900 !outline-none dark:bg-neutral-800 dark:text-neutral-100"
/>
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-neutral-300 px-2.5 text-sm text-neutral-800 hover:bg-neutral-400 hover:text-neutral-900 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600 dark:hover:text-neutral-100"
>
<CopyIcon className="h-4 w-4" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="npub"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Public Key
</label>
<input <input
readOnly readOnly
value={nsec.substring(0, 5) + '**************************************'} name="npub"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/70" value={npub}
className="relative h-12 w-full rounded-lg bg-neutral-200 px-3.5 py-1 text-neutral-900 !outline-none dark:bg-neutral-800 dark:text-neutral-100"
/> />
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-white/20 px-2.5 text-sm hover:bg-white/30"
>
<CopyIcon className="h-4 w-4 text-white/70 group-hover:text-white" />
{copied ? 'Copied' : 'Copy'}
</button>
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium text-white">Public Key</span> <button
<input type="button"
readOnly onClick={() => download()}
value={npub} className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70" >
/> {downloaded ? 'Downloaded' : 'Download account keys'}
</button>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-neutral-200 px-6 font-medium leading-none text-neutral-900 hover:bg-neutral-300 focus:outline-none dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="select-text text-center text-sm text-neutral-400 dark:text-neutral-600">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved
in a safe place. You cannot recover these keys if they are lost.
</span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => download()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
>
{downloaded ? 'Downloaded' : 'Download account keys'}
</button>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white hover:bg-white/30 focus:outline-none"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved in
a safe place. You cannot recover these keys if they are lost.
</span>
</div>
</div> </div>
</div> </div>
); );

225
src/app/auth/import.tsx Normal file
View File

@ -0,0 +1,225 @@
import { motion } from 'framer-motion';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
import { User } from '@shared/user';
export function ImportAccountScreen() {
const { db } = useStorage();
const navigate = useNavigate();
const [npub, setNpub] = useState<string>('');
const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(undefined);
const [created, setCreated] = useState(false);
const [savedPrivkey, setSavedPrivkey] = useState(false);
const submitNpub = async () => {
if (npub.length < 6) return toast('You must enter valid npub');
if (!npub.startsWith('npub1')) return toast('npub must be starts with npub1');
try {
const pubkey = nip19.decode(npub).data as string;
setPubkey(pubkey);
} catch (e) {
return toast(`npub invalid: ${e}`);
}
};
const changeAccount = async () => {
setNpub('');
setPubkey('');
};
const createAccount = async () => {
try {
await db.createAccount(npub, pubkey);
setCreated(true);
} catch (e) {
return toast(`Create account failed: ${e}`);
}
};
const submitNsec = async () => {
if (savedPrivkey) return;
if (nsec.length > 50 && nsec.startsWith('nsec1')) {
try {
const privkey = nip19.decode(nsec).data as string;
await db.secureSave(pubkey, privkey);
setSavedPrivkey(true);
} catch (e) {
return toast(`nsec invalid: ${e}`);
}
}
};
const finish = async () => {
navigate('/auth/onboarding');
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Import your Nostr account
</h1>
<div className="flex flex-col gap-3">
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold">
Enter your nostr npub:
</label>
<div className="inline-flex w-full items-center gap-2">
<input
type="text"
value={npub}
onChange={(e) => setNpub(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="npub1"
className="h-11 flex-1 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
/>
{!pubkey ? (
<button
type="button"
onClick={submitNpub}
className="h-11 w-24 shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
) : null}
</div>
</div>
</div>
{pubkey ? (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -100 } }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<h5 className="mb-1.5 font-semibold">Account found</h5>
<div className="flex w-full flex-col gap-2">
<div className="inline-flex h-full flex-1 items-center rounded-lg bg-neutral-200 p-2">
<User pubkey={pubkey} variant="simple" />
</div>
{!created ? (
<div className="flex gap-2">
<button
type="button"
onClick={changeAccount}
className="h-9 flex-1 shrink-0 rounded-lg bg-neutral-200 font-semibold text-neutral-800 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
>
Change account
</button>
<button
type="button"
onClick={createAccount}
className="h-9 flex-1 shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
</div>
) : null}
</div>
</motion.div>
) : null}
{created ? (
<>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -100 } }}
className="rounded-lg bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold">
Enter your nostr nsec (optional):
</label>
<div className="inline-flex w-full items-center gap-2">
<input
type="text"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="nsec1"
className="h-11 flex-1 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submitNsec}
className={twMerge(
'h-11 w-24 shrink-0 rounded-lg font-semibold text-white',
!savedPrivkey
? 'bg-blue-500 hover:bg-blue-600'
: 'bg-teal-500 hover:bg-teal-600'
)}
>
{savedPrivkey ? 'Saved' : 'Save'}
</button>
</div>
</div>
<div className="mt-3 select-text">
<p className="text-sm">
<b>nsec</b> is used to sign your event. For example, if you want to
make a new post or send a message to your contact, you need to use
nsec to sign this event.
</p>
<h5 className="mt-2 font-semibold">
1. In case you store nsec in Lume
</h5>
<p className="text-sm">
Lume will put your nsec to{' '}
{db.platform === 'macos'
? 'Apple Keychain (macOS)'
: db.platform === 'windows'
? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'}
, it will be secured by your OS
</p>
<h5 className="mt-2 font-semibold">
2. In case you do not store nsec in Lume
</h5>
<p className="text-sm">
When you make an event that requires a sign by your nsec, Lume will
show a prompt popup for you to enter nsec. It will be cleared after
signing and not stored anywhere.
</p>
</div>
</motion.div>
<motion.button
type="button"
onClick={finish}
initial={{ opacity: 0, y: 80 }}
animate={{
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -130 } }}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Finish
</motion.button>
</>
) : null}
</div>
</div>
</div>
);
}

View File

@ -1,9 +0,0 @@
import { Outlet } from 'react-router-dom';
export function AuthImportScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
}

View File

@ -1,156 +0,0 @@
import { getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
type FormValues = {
privkey: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.privkey ? values : {},
errors: !values.privkey
? {
privkey: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function ImportStep1Screen() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [passwordInput, setPasswordInput] = useState('password');
const [setStep, setPubkey, setTempPrivkey] = useOnboarding((state) => [
state.setStep,
state.setPubkey,
state.setTempPrivkey,
]);
const { db } = useStorage();
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
try {
setLoading(true);
let privkey = data['privkey'];
if (privkey.substring(0, 4) === 'nsec') {
privkey = nip19.decode(privkey).data as string;
}
if (typeof getPublicKey(privkey) === 'string') {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
setTempPrivkey(privkey);
setPubkey(pubkey);
// add account to local database
await db.createAccount(npub, pubkey);
// redirect to step 2 with delay 1.2s
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
}
} catch (error) {
setLoading(false);
setError('privkey', {
type: 'custom',
message: 'Private key is invalid, please check again',
});
}
};
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
Import your Nostr key
</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-white">
Insert your nostr private key, in nsec or hex format
</label>
<div className="relative">
<input
{...register('privkey', { required: true, minLength: 32 })}
type={passwordInput}
placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3 py-1 text-white backdrop-blur-xl placeholder:text-white/70 focus:outline-none"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-500">
{errors.privkey && <p>{errors.privkey.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Importing...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,90 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
export function ImportStep2Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const { db } = useStorage();
const { fetchUserData } = useNostr();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
// show loading indicator
setLoading(true);
// prefetch data
const user = await fetchUserData();
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
// redirect to next step
if (user.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
setLoading(false);
}
} catch (e) {
console.log('error: ', e);
setLoading(false);
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import/step-3');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
{loading ? 'Downloading...' : 'Your Nostr profile'}
</h1>
</div>
<div className="flex flex-col gap-3">
<div className="rounded-lg border-t border-white/10 bg-white/20 px-3 py-3">
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="flex flex-col gap-2">
<button
type="button"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
onClick={() => submit()}
>
{loading ? (
<>
<span className="w-5" />
<span>It might take a bit, please patient...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download your old relay list and
metadata. It may take a bit
</span>
</div>
</div>
</div>
);
}

View File

@ -19,13 +19,11 @@ export class LumeStorage {
} }
public async secureSave(value: string, key?: string) { public async secureSave(value: string, key?: string) {
return await invoke('secure_save', { key: this.account.pubkey ?? key, value }); return await invoke('secure_save', { key, value });
} }
public async secureLoad(key?: string) { public async secureLoad(key?: string) {
const value: string = await invoke('secure_load', { const value: string = await invoke('secure_load', { key });
key: this.account.pubkey ?? key,
});
return value; return value;
} }

View File

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
import { NDKProvider } from '@libs/ndk/provider'; import { NDKProvider } from '@libs/ndk/provider';
import { StorageProvider } from '@libs/storage/provider'; import { StorageProvider } from '@libs/storage/provider';
@ -15,6 +16,7 @@ root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<StorageProvider> <StorageProvider>
<NDKProvider> <NDKProvider>
<Toaster />
<App /> <App />
</NDKProvider> </NDKProvider>
</StorageProvider> </StorageProvider>

View File

@ -162,18 +162,18 @@ export const User = memo(function User({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: 'auto' }} style={{ contentVisibility: 'auto' }}
className="h-10 w-10 rounded-lg" className="h-11 w-11 rounded-lg"
/> />
<Avatar.Fallback delayMs={300}> <Avatar.Fallback delayMs={300}>
<img <img
src={svgURI} src={svgURI}
alt={pubkey} alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white" className="h-11 w-11 rounded-lg bg-black dark:bg-white"
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div className="flex w-full flex-col items-start"> <div className="flex w-full flex-col items-start">
<h3 className="max-w-[15rem] truncate font-medium text-neutral-900 dark:text-neutral-100"> <h3 className="max-w-[15rem] truncate text-base font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName} {user?.name || user?.display_name || user?.displayName}
</h3> </h3>
<p className="max-w-[10rem] truncate text-sm text-neutral-900 dark:text-neutral-100/70"> <p className="max-w-[10rem] truncate text-sm text-neutral-900 dark:text-neutral-100/70">