mirror of
https://github.com/luminous-devs/lume.git
synced 2024-10-01 09:21:07 +00:00
wip
This commit is contained in:
parent
21d22320b3
commit
85b30f770c
18
.eslintrc
18
.eslintrc
@ -1,18 +0,0 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "react-refresh"],
|
||||
"extends": [
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"react-refresh/only-export-components": "error",
|
||||
"react/no-unknown-property": ["error", { "ignore": ["fetchpriority"] }]
|
||||
},
|
||||
"ignorePatterns": ["dist", "**/*.js", "**/*.json", "node_modules"]
|
||||
}
|
7
global.d.ts
vendored
7
global.d.ts
vendored
@ -1,7 +0,0 @@
|
||||
import { AriaAttributes, DOMAttributes } from 'react';
|
||||
|
||||
declare module 'react' {
|
||||
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
}
|
11
index.html
Normal file
11
index.html
Normal file
@ -0,0 +1,11 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume</title>
|
||||
</head>
|
||||
<body class="cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen dark:bg-black dark:text-zinc-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
10
package.json
10
package.json
@ -16,11 +16,12 @@
|
||||
"@floating-ui/react": "^0.23.1",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@nostr-dev-kit/ndk": "^0.5.13",
|
||||
"@tanstack/react-query": "^4.29.15",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "^1.4.0",
|
||||
"@vidstack/react": "^0.4.5",
|
||||
"dayjs": "^1.11.8",
|
||||
"destr": "^1.2.2",
|
||||
"framer-motion": "^10.12.17",
|
||||
"get-urls": "^11.0.0",
|
||||
"immer": "^10.0.2",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
@ -30,12 +31,12 @@
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-hotkeys-hook": "^4.4.0",
|
||||
"react-resizable-panels": "^0.0.48",
|
||||
"react-router-dom": "^6.14.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-virtuoso": "^4.3.10",
|
||||
"react-virtuoso": "^4.3.11",
|
||||
"slate": "^0.94.1",
|
||||
"slate-history": "^0.93.0",
|
||||
"slate-react": "^0.94.2",
|
||||
"swr": "^2.1.5",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
||||
"vidstack": "^0.4.5",
|
||||
@ -45,7 +46,7 @@
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tauri-apps/cli": "^1.4.0",
|
||||
"@types/node": "^18.16.18",
|
||||
"@types/react": "^18.2.13",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/youtube-player": "^5.5.7",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
@ -61,7 +62,6 @@
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-ssr": "^0.4.131",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-tsconfig-paths": "^4.2.0"
|
||||
}
|
||||
|
268
pnpm-lock.yaml
268
pnpm-lock.yaml
@ -10,21 +10,24 @@ dependencies:
|
||||
'@nostr-dev-kit/ndk':
|
||||
specifier: ^0.5.13
|
||||
version: 0.5.13(typescript@4.9.5)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^4.29.15
|
||||
version: 4.29.15(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.0.0-beta.54
|
||||
version: 3.0.0-beta.54(react@18.2.0)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@vidstack/react':
|
||||
specifier: ^0.4.5
|
||||
version: 0.4.5(@types/react@18.2.13)(maverick.js@0.33.1)(media-icons@0.4.2)(react@18.2.0)(vidstack@0.4.5)
|
||||
dayjs:
|
||||
specifier: ^1.11.8
|
||||
version: 1.11.8
|
||||
destr:
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2
|
||||
framer-motion:
|
||||
specifier: ^10.12.17
|
||||
version: 10.12.17(react-dom@18.2.0)(react@18.2.0)
|
||||
get-urls:
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
@ -52,12 +55,15 @@ dependencies:
|
||||
react-resizable-panels:
|
||||
specifier: ^0.0.48
|
||||
version: 0.0.48(react-dom@18.2.0)(react@18.2.0)
|
||||
react-router-dom:
|
||||
specifier: ^6.14.0
|
||||
version: 6.14.0(react-dom@18.2.0)(react@18.2.0)
|
||||
react-string-replace:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
react-virtuoso:
|
||||
specifier: ^4.3.10
|
||||
version: 4.3.10(react-dom@18.2.0)(react@18.2.0)
|
||||
specifier: ^4.3.11
|
||||
version: 4.3.11(react-dom@18.2.0)(react@18.2.0)
|
||||
slate:
|
||||
specifier: ^0.94.1
|
||||
version: 0.94.1
|
||||
@ -67,9 +73,6 @@ dependencies:
|
||||
slate-react:
|
||||
specifier: ^0.94.2
|
||||
version: 0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1)
|
||||
swr:
|
||||
specifier: ^2.1.5
|
||||
version: 2.1.5(react@18.2.0)
|
||||
tailwind-merge:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
@ -94,8 +97,8 @@ devDependencies:
|
||||
specifier: ^18.16.18
|
||||
version: 18.16.18
|
||||
'@types/react':
|
||||
specifier: ^18.2.13
|
||||
version: 18.2.13
|
||||
specifier: ^18.2.14
|
||||
version: 18.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.6
|
||||
version: 18.2.6
|
||||
@ -141,9 +144,6 @@ devDependencies:
|
||||
vite:
|
||||
specifier: ^4.3.9
|
||||
version: 4.3.9(@types/node@18.16.18)
|
||||
vite-plugin-ssr:
|
||||
specifier: ^0.4.131
|
||||
version: 0.4.131(vite@4.3.9)
|
||||
vite-plugin-top-level-await:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(vite@4.3.9)
|
||||
@ -179,23 +179,18 @@ packages:
|
||||
js-tokens: 4.0.0
|
||||
dev: false
|
||||
|
||||
/@brillout/import@0.2.3:
|
||||
resolution: {integrity: sha512-1T8WlD75eeFSMrptGy8jiLHmfHgMmSjWvLOIUvHmSVZt+6k0eQqYUoK4KbmE4T9pVLIfxvZSOm2D68VEqKRHRw==}
|
||||
dev: true
|
||||
|
||||
/@brillout/json-serializer@0.5.3:
|
||||
resolution: {integrity: sha512-IxlOMD5gOM0WfFGdeR98jHKiC82Ad1tUnSjvLS5jnRkfMEKBI+YzHA32Umw8W3Ccp5N4fNEX229BW6RaRpxRWQ==}
|
||||
dev: true
|
||||
|
||||
/@brillout/picocolors@1.0.4:
|
||||
resolution: {integrity: sha512-rhZBVyrRCb53T9xIGoEjZQ6O4Um3XQWcQ1z2VL2eBQBtJYCsABUUNE/isqbnts3XD1sAkisDF2L3OjJeIgrznQ==}
|
||||
dev: true
|
||||
|
||||
/@brillout/vite-plugin-import-build@0.2.18:
|
||||
resolution: {integrity: sha512-sedZNrqIboHCeSnN7hwo34xRyP8egfMHcifixQ2YGNnQVb93884drTVE3b0vlSGz7LWumVDochKuHdWQljup9A==}
|
||||
/@emotion/is-prop-valid@0.8.8:
|
||||
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@brillout/import': 0.2.3
|
||||
dev: true
|
||||
'@emotion/memoize': 0.7.4
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@emotion/memoize@0.7.4:
|
||||
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@esbuild/android-arm64@0.17.19:
|
||||
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
|
||||
@ -533,8 +528,8 @@ packages:
|
||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||
dev: false
|
||||
|
||||
/@maverick-js/signals@5.11.1:
|
||||
resolution: {integrity: sha512-yr6ZIpQxupgbDl6364t1L7bOSJqK2xiwjf8hoxJ632SL+0nBL0bLNCQaiK4GFRQEhYhBwX2yYyA+eKgZLEJ6mg==}
|
||||
/@maverick-js/signals@5.11.2:
|
||||
resolution: {integrity: sha512-jKAyNE2O7H+xrigPoqdV0Iq2AeQ6cysfBf/b2jasJ4FfCUKjGyazgtp+pIspTW6skFvpPrvq40Qft+7HuR+Tlg==}
|
||||
dev: false
|
||||
|
||||
/@noble/curves@1.0.0:
|
||||
@ -607,7 +602,7 @@ packages:
|
||||
resolution: {integrity: sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
dependencies:
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
dev: false
|
||||
|
||||
/@pkgjs/parseargs@0.11.0:
|
||||
@ -617,9 +612,10 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@polka/url@1.0.0-next.21:
|
||||
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
|
||||
dev: true
|
||||
/@remix-run/router@1.7.0:
|
||||
resolution: {integrity: sha512-Eu1V3kz3mV0wUpVTiFHuaT8UD1gj/0VnoFHQYX35xlslQUpe8CuYoKFn9d4WZFHm3yDywz6ALZuGdnUPKrNeAw==}
|
||||
engines: {node: '>=14'}
|
||||
dev: false
|
||||
|
||||
/@rollup/plugin-virtual@3.0.1:
|
||||
resolution: {integrity: sha512-fK8O0IL5+q+GrsMLuACVNk2x21g3yaw+sG2qn16SnUd3IlBsQyvWxLMGHmCmXRMecPjGRSZ/1LmZB4rjQm68og==}
|
||||
@ -826,6 +822,28 @@ packages:
|
||||
tailwindcss: 3.3.2
|
||||
dev: true
|
||||
|
||||
/@tanstack/query-core@4.29.15:
|
||||
resolution: {integrity: sha512-Recc1d5rjHesKhzlH3Aw66v+vQxtB9OHEXP/vxgEcEJ0DwEpfe3EQ4id20vuBJHY2XRjfgWGmUs6ZgK6PSsTXA==}
|
||||
dev: false
|
||||
|
||||
/@tanstack/react-query@4.29.15(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-1zDkv95ljuJ623hhbYU8YIprPW2x6774kh3IQNEuZav62+S+Zr26uUOrE2zGRp9I1uO5Liw/0uYB3dWXQP5+3Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-native: '*'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@tanstack/query-core': 4.29.15
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@tanstack/react-virtual@3.0.0-beta.54(react@18.2.0):
|
||||
resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
|
||||
peerDependencies:
|
||||
@ -1001,22 +1019,25 @@ packages:
|
||||
|
||||
/@types/prop-types@15.7.5:
|
||||
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
|
||||
dev: true
|
||||
|
||||
/@types/react-dom@18.2.6:
|
||||
resolution: {integrity: sha512-2et4PDvg6PVCyS7fuTc4gPoksV58bW0RwSxWKcPRcHZf0PRUGq03TKcD/rUHe3azfV6/5/biUBJw+HhCQjaP0A==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.13
|
||||
'@types/react': 18.2.14
|
||||
dev: true
|
||||
|
||||
/@types/react@18.2.13:
|
||||
resolution: {integrity: sha512-vJ+zElvi/Zn9cVXB5slX2xL8PZodPCwPRDpittQdw43JR2AJ5k3vKdgJJyneV/cYgIbLQUwXa9JVDvUZXGba+Q==}
|
||||
/@types/react@18.2.14:
|
||||
resolution: {integrity: sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==}
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.5
|
||||
'@types/scheduler': 0.16.3
|
||||
csstype: 3.1.2
|
||||
dev: true
|
||||
|
||||
/@types/scheduler@0.16.3:
|
||||
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
|
||||
dev: true
|
||||
|
||||
/@types/semver@7.5.0:
|
||||
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
|
||||
@ -1047,7 +1068,7 @@ packages:
|
||||
grapheme-splitter: 1.0.4
|
||||
ignore: 5.2.4
|
||||
natural-compare-lite: 1.4.0
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
tsutils: 3.21.0(typescript@4.9.5)
|
||||
typescript: 4.9.5
|
||||
transitivePeerDependencies:
|
||||
@ -1121,7 +1142,7 @@ packages:
|
||||
debug: 4.3.4
|
||||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
tsutils: 3.21.0(typescript@4.9.5)
|
||||
typescript: 4.9.5
|
||||
transitivePeerDependencies:
|
||||
@ -1142,7 +1163,7 @@ packages:
|
||||
'@typescript-eslint/typescript-estree': 5.60.0(typescript@4.9.5)
|
||||
eslint: 8.43.0
|
||||
eslint-scope: 5.1.1
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@ -1156,23 +1177,6 @@ packages:
|
||||
eslint-visitor-keys: 3.4.1
|
||||
dev: false
|
||||
|
||||
/@vidstack/react@0.4.5(@types/react@18.2.13)(maverick.js@0.33.1)(media-icons@0.4.2)(react@18.2.0)(vidstack@0.4.5):
|
||||
resolution: {integrity: sha512-spcim3+p1fMzkhHRKn5PS54YQjfThW5M3F2+R8tCT+wpsxbbCDa/TGdLBoIy2oC0LNziPkn0vlBWIZko9F5iig==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.0.0
|
||||
maverick.js: 0.33.1
|
||||
media-icons: ^0.4.2
|
||||
react: ^18.0.0
|
||||
vidstack: 0.4.5
|
||||
dependencies:
|
||||
'@types/react': 18.2.13
|
||||
maverick.js: 0.33.1
|
||||
media-icons: 0.4.2
|
||||
react: 18.2.0
|
||||
vidstack: 0.4.5
|
||||
dev: false
|
||||
|
||||
/@vitejs/plugin-react-swc@3.3.2(vite@4.3.9):
|
||||
resolution: {integrity: sha512-VJFWY5sfoZerQRvJrh518h3AcQt6f/yTuWn4/TRB+dqmYU0NX1qz7qM5Wfd+gOQqUzQW4gxKqKN3KpE/P3+zrA==}
|
||||
peerDependencies:
|
||||
@ -1200,6 +1204,7 @@ packages:
|
||||
resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
@ -1373,7 +1378,7 @@ packages:
|
||||
postcss: ^8.1.0
|
||||
dependencies:
|
||||
browserslist: 4.21.9
|
||||
caniuse-lite: 1.0.30001506
|
||||
caniuse-lite: 1.0.30001507
|
||||
fraction.js: 4.2.0
|
||||
normalize-range: 0.1.2
|
||||
picocolors: 1.0.0
|
||||
@ -1417,8 +1422,8 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001506
|
||||
electron-to-chromium: 1.4.435
|
||||
caniuse-lite: 1.0.30001507
|
||||
electron-to-chromium: 1.4.440
|
||||
node-releases: 2.0.12
|
||||
update-browserslist-db: 1.0.11(browserslist@4.21.9)
|
||||
dev: true
|
||||
@ -1431,18 +1436,13 @@ packages:
|
||||
node-gyp-build: 4.6.0
|
||||
dev: false
|
||||
|
||||
/cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/cacache@17.1.3:
|
||||
resolution: {integrity: sha512-jAdjGxmPxZh0IipMdR7fK/4sDSrHMLUV0+GvVUsjwyGNKHsh79kW/otg+GkbXwl6Uzvy9wsvHOX4nUoWldeZMg==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
dependencies:
|
||||
'@npmcli/fs': 3.1.0
|
||||
fs-minipass: 3.0.2
|
||||
glob: 10.2.7
|
||||
glob: 10.3.0
|
||||
lru-cache: 7.18.3
|
||||
minipass: 5.0.0
|
||||
minipass-collect: 1.0.2
|
||||
@ -1485,8 +1485,8 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/caniuse-lite@1.0.30001506:
|
||||
resolution: {integrity: sha512-6XNEcpygZMCKaufIcgpQNZNf00GEqc7VQON+9Rd0K1bMYo8xhMZRAo5zpbnbMNizi4YNgIDAFrdykWsvY3H4Hw==}
|
||||
/caniuse-lite@1.0.30001507:
|
||||
resolution: {integrity: sha512-SFpUDoSLCaE5XYL2jfqe9ova/pbQHEmbheDf5r4diNwbAgR3qxM9NQtfsiSscjqoya5K7kFcHPUQ+VsUkIJR4A==}
|
||||
dev: true
|
||||
|
||||
/chalk@2.4.2:
|
||||
@ -1615,7 +1615,7 @@ packages:
|
||||
resolution: {integrity: sha512-cllzD6IU/mzXBs5OdQVWL3+ne5Elpu3Wdm7h5OldMbGXk76yr9XzHlQXWJ4zfs0ZAibe26rkbs4KvMAJm7fIZA==}
|
||||
engines: {node: '>=14.x'}
|
||||
dependencies:
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
dev: false
|
||||
|
||||
/cross-env@7.0.3:
|
||||
@ -1653,6 +1653,7 @@ packages:
|
||||
|
||||
/csstype@3.1.2:
|
||||
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
|
||||
dev: true
|
||||
|
||||
/d@1.0.1:
|
||||
resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==}
|
||||
@ -1783,8 +1784,8 @@ packages:
|
||||
/eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
/electron-to-chromium@1.4.435:
|
||||
resolution: {integrity: sha512-B0CBWVFhvoQCW/XtjRzgrmqcgVWg6RXOEM/dK59+wFV93BFGR6AeNKc4OyhM+T3IhJaOOG8o/V+33Y2mwJWtzw==}
|
||||
/electron-to-chromium@1.4.440:
|
||||
resolution: {integrity: sha512-r6dCgNpRhPwiWlxbHzZQ/d9swfPaEJGi8ekqRBwQYaR3WmA5VkqQfBWSDDjuJU1ntO+W9tHx8OHV/96Q8e0dVw==}
|
||||
dev: true
|
||||
|
||||
/emoji-regex@8.0.0:
|
||||
@ -1853,10 +1854,6 @@ packages:
|
||||
which-typed-array: 1.1.9
|
||||
dev: false
|
||||
|
||||
/es-module-lexer@0.10.5:
|
||||
resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==}
|
||||
dev: true
|
||||
|
||||
/es-set-tostringtag@2.0.1:
|
||||
resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -2127,7 +2124,7 @@ packages:
|
||||
dependencies:
|
||||
create-esm-loader: 0.2.3
|
||||
npm-run-all: 4.1.5
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
typescript: 5.1.3
|
||||
dev: false
|
||||
|
||||
@ -2299,6 +2296,24 @@ packages:
|
||||
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
|
||||
dev: true
|
||||
|
||||
/framer-motion@10.12.17(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-IR+aAYntsyu6ofyxqQV4QYotmOqzcuKxhqNpfc3DXJjNWOPpOeSyH0A+In3IEBu49Yx/+PNht+YMeZSdCNaYbw==}
|
||||
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.5.3
|
||||
optionalDependencies:
|
||||
'@emotion/is-prop-valid': 0.8.8
|
||||
dev: false
|
||||
|
||||
/fs-minipass@2.1.0:
|
||||
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -2400,14 +2415,14 @@ packages:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
/glob@10.2.7:
|
||||
resolution: {integrity: sha512-jTKehsravOJo8IJxUGfZILnkvVJM/MOfHRs8QcXolVef2zNI9Tqyy5+SeuOAZd3upViEZQLyFpQhYiHLrMUNmA==}
|
||||
/glob@10.3.0:
|
||||
resolution: {integrity: sha512-AQ1/SB9HH0yCx1jXAT4vmCbTOPe5RQ+kCurjbel5xSCGhebumUv+GJZfa1rEqor3XIViqwSEmlkZCQD43RWrBg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
foreground-child: 3.1.1
|
||||
jackspeak: 2.2.1
|
||||
minimatch: 9.0.1
|
||||
minimatch: 9.0.2
|
||||
minipass: 5.0.0
|
||||
path-scurry: 1.9.2
|
||||
dev: false
|
||||
@ -3078,7 +3093,7 @@ packages:
|
||||
resolution: {integrity: sha512-p8L5V62CV6TmHAngmRAopp231oJKeH77mJja5SsKOfvzrPRoThT/Jo9U0jMRB5iMykqkvyg2J5V5Agn6FPXDWQ==}
|
||||
engines: {node: '>=16'}
|
||||
dependencies:
|
||||
'@maverick-js/signals': 5.11.1
|
||||
'@maverick-js/signals': 5.11.2
|
||||
type-fest: 3.12.0
|
||||
dev: false
|
||||
|
||||
@ -3150,8 +3165,8 @@ packages:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.11
|
||||
|
||||
/minimatch@9.0.1:
|
||||
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
|
||||
/minimatch@9.0.2:
|
||||
resolution: {integrity: sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
dependencies:
|
||||
brace-expansion: 2.0.1
|
||||
@ -3235,11 +3250,6 @@ packages:
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/mrmime@1.0.1:
|
||||
resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
dev: false
|
||||
@ -3322,7 +3332,7 @@ packages:
|
||||
nopt: 6.0.0
|
||||
npmlog: 6.0.2
|
||||
rimraf: 3.0.2
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
tar: 6.1.15
|
||||
which: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
@ -3356,7 +3366,7 @@ packages:
|
||||
dependencies:
|
||||
hosted-git-info: 4.1.0
|
||||
is-core-module: 2.12.1
|
||||
semver: 7.5.2
|
||||
semver: 7.5.3
|
||||
validate-npm-package-license: 3.0.4
|
||||
dev: false
|
||||
|
||||
@ -3817,13 +3827,36 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-router-dom@6.14.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-YEwlApKwzMMMbGbhh+Q7MsloTldcwMgHxUY/1g0uA62+B1hZo2jsybCWIDCL8zvIDB1FA0pBKY9chHbZHt+2dQ==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
dependencies:
|
||||
'@remix-run/router': 1.7.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-router: 6.14.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-router@6.14.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-OD+vkrcGbvlwkspUFDgMzsu1RXwdjNh83YgG/28lBnDzgslhCgxIqoExLlxsfTpIygp7fc+Hd3esloNwzkm2xA==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
dependencies:
|
||||
'@remix-run/router': 1.7.0
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react-string-replace@1.1.1:
|
||||
resolution: {integrity: sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
dev: false
|
||||
|
||||
/react-virtuoso@4.3.10(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-/LDICLCmPRDMOheCKmuHj/U7CGjLT/WtMWZGfOothhhubQeWGbR6mtGyd+uD80Yw/n3ICZtYwERQZnTM8eC0ag==}
|
||||
/react-virtuoso@4.3.11(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-0YrCvQ5GsIKRcN34GxrzhSJGuMNI+hGxWci5cTVuPQ8QWTEsrKfCyqm7YNBMmV3pu7onG1YVUBo86CyCXdejXg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16 || >=17 || >= 18'
|
||||
@ -4018,8 +4051,8 @@ packages:
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/semver@7.5.2:
|
||||
resolution: {integrity: sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==}
|
||||
/semver@7.5.3:
|
||||
resolution: {integrity: sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
@ -4072,15 +4105,6 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
dev: false
|
||||
|
||||
/sirv@2.0.3:
|
||||
resolution: {integrity: sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==}
|
||||
engines: {node: '>= 10'}
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.21
|
||||
mrmime: 1.0.1
|
||||
totalist: 3.0.1
|
||||
dev: true
|
||||
|
||||
/slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@ -4343,15 +4367,6 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
/swr@2.1.5(react@18.2.0):
|
||||
resolution: {integrity: sha512-/OhfZMcEpuz77KavXST5q6XE9nrOBOVcBLWjMT+oAE/kQHyE3PASrevXCtQDZ8aamntOfFkbVJp7Il9tNBQWrw==}
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/tabbable@6.1.2:
|
||||
resolution: {integrity: sha512-qCN98uP7i9z0fIS4amQ5zbGBOq+OSigYeGvPy7NDk8Y9yncqDZ9pRPgfsc2PJIVM9RrJj7GIfuRgmjoUU9zTHQ==}
|
||||
dev: false
|
||||
@ -4444,11 +4459,6 @@ packages:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
/totalist@3.0.1:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/trim-newlines@3.0.1:
|
||||
resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
|
||||
engines: {node: '>=8'}
|
||||
@ -4684,30 +4694,6 @@ packages:
|
||||
type-fest: 3.12.0
|
||||
dev: false
|
||||
|
||||
/vite-plugin-ssr@0.4.131(vite@4.3.9):
|
||||
resolution: {integrity: sha512-M8ay5UuQUEMBHhg87BVhXGjPV+/xO3PZWzZ63S8J8rX4Xv+sQbnDswGYm/TJ+Ga/9NTgrFDAKqUfEb3pEpd+Aw==}
|
||||
engines: {node: '>=12.19.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
react-streaming: '>=0.3.5'
|
||||
vite: '>=3.1.0'
|
||||
peerDependenciesMeta:
|
||||
react-streaming:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@brillout/import': 0.2.3
|
||||
'@brillout/json-serializer': 0.5.3
|
||||
'@brillout/picocolors': 1.0.4
|
||||
'@brillout/vite-plugin-import-build': 0.2.18
|
||||
acorn: 8.9.0
|
||||
cac: 6.7.14
|
||||
es-module-lexer: 0.10.5
|
||||
esbuild: 0.17.19
|
||||
fast-glob: 3.2.12
|
||||
sirv: 2.0.3
|
||||
vite: 4.3.9(@types/node@18.16.18)
|
||||
dev: true
|
||||
|
||||
/vite-plugin-top-level-await@1.3.1(vite@4.3.9):
|
||||
resolution: {integrity: sha512-55M1h4NAwkrpxPNOJIBzKZFihqLUzIgnElLSmPNPMR2Fn9+JHKaNg3sVX1Fq+VgvuBksQYxiD3OnwQAUu7kaPQ==}
|
||||
peerDependencies:
|
||||
|
@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
@ -3,7 +3,7 @@
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"devPath": "http://localhost:3000",
|
||||
"distDir": "../dist/client",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": true
|
||||
},
|
||||
"package": {
|
||||
|
91
src/app.tsx
Normal file
91
src/app.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import "./index.css";
|
||||
import { AuthCreateScreen } from "@app/auth/create";
|
||||
import { CreateStep1Screen } from "@app/auth/create/step-1";
|
||||
import { CreateStep2Screen } from "@app/auth/create/step-2";
|
||||
import { CreateStep3Screen } from "@app/auth/create/step-3";
|
||||
import { CreateStep4Screen } from "@app/auth/create/step-4";
|
||||
import { AuthImportScreen } from "@app/auth/import";
|
||||
import { ImportStep1Screen } from "@app/auth/import/step-1";
|
||||
import { ImportStep2Screen } from "@app/auth/import/step-2";
|
||||
import { OnboardingScreen } from "@app/auth/onboarding";
|
||||
import { WelcomeScreen } from "@app/auth/welcome";
|
||||
import { ChannelScreen } from "@app/channel";
|
||||
import { ChatScreen } from "@app/chat";
|
||||
import { ErrorScreen } from "@app/error";
|
||||
import { Root } from "@app/root";
|
||||
import { SpaceScreen } from "@app/space";
|
||||
import { TrendingScreen } from "@app/trending";
|
||||
import { AppLayout } from "@shared/appLayout";
|
||||
import { AuthLayout } from "@shared/authLayout";
|
||||
import { Protected } from "@shared/protected";
|
||||
import { RelayProvider } from "@shared/relayProvider";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<Protected>
|
||||
<Root />
|
||||
</Protected>
|
||||
),
|
||||
errorElement: <ErrorScreen />,
|
||||
},
|
||||
{
|
||||
path: "/auth",
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{ path: "welcome", element: <WelcomeScreen /> },
|
||||
{ path: "onboarding", element: <OnboardingScreen /> },
|
||||
{
|
||||
path: "import",
|
||||
element: <AuthImportScreen />,
|
||||
children: [
|
||||
{ path: "", element: <ImportStep1Screen /> },
|
||||
{ path: "step-2", element: <ImportStep2Screen /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "create",
|
||||
element: <AuthCreateScreen />,
|
||||
children: [
|
||||
{ path: "", element: <CreateStep1Screen /> },
|
||||
{ path: "step-2", element: <CreateStep2Screen /> },
|
||||
{ path: "step-3", element: <CreateStep3Screen /> },
|
||||
{ path: "step-4", element: <CreateStep4Screen /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/app",
|
||||
element: (
|
||||
<Protected>
|
||||
<AppLayout />
|
||||
</Protected>
|
||||
),
|
||||
children: [
|
||||
{ path: "space", element: <SpaceScreen /> },
|
||||
{ path: "trending", element: <TrendingScreen /> },
|
||||
{ path: "chat/:pubkey", element: <ChatScreen /> },
|
||||
{ path: "channel/:id", element: <ChannelScreen /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RelayProvider>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
fallbackElement={<p>Loading..</p>}
|
||||
future={{ v7_startTransition: true }}
|
||||
/>
|
||||
</RelayProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { LayoutOnboarding as Layout } from "./layout";
|
@ -4,39 +4,39 @@ import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
export function User({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink-0 rounded-md bg-zinc-800 animate-pulse" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||
<span className="w-full h-4 rounded bg-zinc-800 animate-pulse" />
|
||||
<span className="w-1/2 h-3 rounded bg-zinc-800 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { status, user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink rounded-md">
|
||||
<Image
|
||||
src={user.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-100">
|
||||
{user.displayName || user.name}
|
||||
</span>
|
||||
<span className="text-base leading-tight text-zinc-400">
|
||||
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
{status === "loading" ? (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink-0 rounded-md bg-zinc-800 animate-pulse" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||
<span className="w-1/2 h-4 rounded bg-zinc-800 animate-pulse" />
|
||||
<span className="w-1/3 h-3 rounded bg-zinc-800 animate-pulse" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink rounded-md">
|
||||
<Image
|
||||
src={user.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-100">
|
||||
{user.displayName || user.name}
|
||||
</span>
|
||||
<span className="text-base leading-tight text-zinc-400">
|
||||
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
9
src/app/auth/create/index.tsx
Normal file
9
src/app/auth/create/index.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export function AuthCreateScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
105
src/app/auth/create/step-1.tsx
Normal file
105
src/app/auth/create/step-1.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { createAccount } from "@libs/storage";
|
||||
import { Button } from "@shared/button";
|
||||
import { EyeOffIcon, EyeOnIcon } from "@shared/icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function CreateStep1Screen() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [type, setType] = useState("password");
|
||||
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(privkey);
|
||||
|
||||
// toggle private key
|
||||
const showPrivateKey = () => {
|
||||
if (type === "password") {
|
||||
setType("text");
|
||||
} else {
|
||||
setType("password");
|
||||
}
|
||||
};
|
||||
|
||||
const account = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
createAccount(data.npub, data.pubkey, data.privkey, null, 1),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
|
||||
// redirect to next step
|
||||
navigate("/auth/create/step-2", { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
account.mutate({
|
||||
npub,
|
||||
pubkey,
|
||||
privkey,
|
||||
follows: null,
|
||||
is_active: 1,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Lume is auto-generated key for you
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-base font-semibold text-zinc-400">
|
||||
Public Key
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-base font-semibold text-zinc-400">
|
||||
Private Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{type === "password" ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
Continue →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
112
src/app/auth/create/step-2.tsx
Normal file
112
src/app/auth/create/step-2.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { Image } from "@shared/image";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useOnboarding } from "@stores/onboarding";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function CreateStep2Screen() {
|
||||
const navigate = useNavigate();
|
||||
const createProfile = useOnboarding((state: any) => state.createProfile);
|
||||
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const profile = { ...data, name: data.displayName };
|
||||
createProfile(profile);
|
||||
// redirect to step 3
|
||||
navigate("/auth/create/step-3");
|
||||
} catch {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Create your profile
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-5">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<input
|
||||
type={"hidden"}
|
||||
{...register("picture")}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Avatar
|
||||
</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image
|
||||
src={image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt="avatar"
|
||||
className="relative z-10 h-11 w-11 rounded-md"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Display Name *
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("displayName", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="resize-none relative h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
95
src/app/auth/create/step-3.tsx
Normal file
95
src/app/auth/create/step-3.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { Button } from "@shared/button";
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useOnboarding } from "@stores/onboarding";
|
||||
import { Body, fetch } from "@tauri-apps/api/http";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
export function CreateStep3Screen() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const profile = useOnboarding((state: any) => state.profile);
|
||||
const { account } = useAccount();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const createNIP05 = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch("https://lume.nu/api/user-create", {
|
||||
method: "POST",
|
||||
timeout: 30,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: Body.json({
|
||||
username: username,
|
||||
pubkey: account.pubkey,
|
||||
lightningAddress: "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = { ...profile, nip05: `${username}@lume.nu` };
|
||||
|
||||
const signer = new NDKPrivateKeySigner(account.privkey);
|
||||
ndk.signer = signer;
|
||||
|
||||
const event = new NDKEvent(ndk);
|
||||
// build event
|
||||
event.content = JSON.stringify(data);
|
||||
event.kind = 0;
|
||||
event.pubkey = account.pubkey;
|
||||
event.tags = [];
|
||||
// publish event
|
||||
event.publish();
|
||||
|
||||
// redirect to step 4
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
console.error("Error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Create your Lume ID
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-800">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoCapitalize="false"
|
||||
autoCorrect="none"
|
||||
spellCheck="false"
|
||||
placeholder="satoshi"
|
||||
className="relative w-full py-3 pl-3.5 !outline-none placeholder:text-zinc-500 bg-transparent text-zinc-100"
|
||||
/>
|
||||
<span className="text-fuchsia-500 font-semibold pr-3.5">
|
||||
@lume.nu
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
preset="large"
|
||||
onClick={() => createNIP05()}
|
||||
disabled={username.length === 0}
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
import { User } from "@app/auth/components/user";
|
||||
import { updateAccount } from "@libs/storage";
|
||||
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { CheckCircleIcon, LoaderIcon } from "@shared/icons";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { arrayToNIP02 } from "@utils/transform";
|
||||
import { useContext, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const initialList = [
|
||||
{
|
||||
@ -106,15 +108,15 @@ const initialList = [
|
||||
},
|
||||
];
|
||||
|
||||
export function Page() {
|
||||
export function CreateStep4Screen() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState([]);
|
||||
const [account, updateFollows] = useActiveAccount((state: any) => [
|
||||
state.account,
|
||||
state.updateFollows,
|
||||
]);
|
||||
|
||||
const { account } = useAccount();
|
||||
|
||||
// toggle follow state
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
@ -124,6 +126,16 @@ export function Page() {
|
||||
setFollows(arr);
|
||||
};
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (follows: any) =>
|
||||
updateAccount("follows", follows, account.pubkey),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
|
||||
// redirect to next step
|
||||
navigate("/auth/onboarding", { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
// save follows to database then broadcast
|
||||
const submit = async () => {
|
||||
try {
|
||||
@ -142,75 +154,64 @@ export function Page() {
|
||||
// publish event
|
||||
event.publish();
|
||||
|
||||
// update account follows
|
||||
updateFollows(follows);
|
||||
|
||||
// redirect to onboarding
|
||||
setTimeout(
|
||||
() =>
|
||||
navigate("/app/onboarding", {
|
||||
overwriteLastHistoryEntry: true,
|
||||
}),
|
||||
2000,
|
||||
);
|
||||
// update
|
||||
update.mutate(follows);
|
||||
} catch {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Personalized your newsfeed
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
|
||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
|
||||
Follow at least
|
||||
<span className="text-fuchsia-500 font-semibold">
|
||||
{follows.length}/10
|
||||
</span>{" "}
|
||||
plebs
|
||||
</div>
|
||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
||||
{initialList.map((item: { pubkey: string }, index: number) => (
|
||||
<button
|
||||
key={`item-${index}`}
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
|
||||
>
|
||||
<User pubkey={item.pubkey} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-green-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Personalized your newsfeed
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
|
||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
|
||||
Follow at least
|
||||
<span className="text-fuchsia-500 font-semibold">
|
||||
{follows.length}/10
|
||||
</span>{" "}
|
||||
plebs
|
||||
</div>
|
||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
||||
{initialList.map((item: { pubkey: string }, index: number) => (
|
||||
<button
|
||||
key={`item-${index}`}
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
|
||||
>
|
||||
<User pubkey={item.pubkey} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-green-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Finish →"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Finish →"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
9
src/app/auth/import/index.tsx
Normal file
9
src/app/auth/import/index.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export function AuthImportScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
111
src/app/auth/import/step-1.tsx
Normal file
111
src/app/auth/import/step-1.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { createAccount } from "@libs/storage";
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { Resolver, useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type FormValues = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.key ? values : {},
|
||||
errors: !values.key
|
||||
? {
|
||||
key: {
|
||||
type: "required",
|
||||
message: "This is required.",
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function ImportStep1Screen() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const account = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
createAccount(data.npub, data.pubkey, data.privkey, null, 1),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
|
||||
// redirect to next step
|
||||
navigate("/auth/import/step-2", { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
let privkey = data["key"];
|
||||
|
||||
if (privkey.substring(0, 4) === "nsec") {
|
||||
privkey = nip19.decode(privkey).data;
|
||||
}
|
||||
|
||||
if (typeof getPublicKey(privkey) === "string") {
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
// update
|
||||
account.mutate({
|
||||
npub,
|
||||
pubkey,
|
||||
privkey,
|
||||
follows: null,
|
||||
is_active: 1,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError("key", {
|
||||
type: "custom",
|
||||
message: "Private Key is invalid, please check again",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<input
|
||||
{...register("key", { required: true, minLength: 32 })}
|
||||
type={"password"}
|
||||
placeholder="Paste private key here..."
|
||||
className="relative w-full rounded-lg px-3 py-3 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
<span className="text-base text-red-400">
|
||||
{errors.key && <p>{errors.key.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
81
src/app/auth/import/step-2.tsx
Normal file
81
src/app/auth/import/step-2.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { User } from "@app/auth/components/user";
|
||||
import { updateAccount } from "@libs/storage";
|
||||
import { Button } from "@shared/button";
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { setToArray } from "@utils/transform";
|
||||
import { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function ImportStep2Screen() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { status, account } = useAccount();
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (follows: any) =>
|
||||
updateAccount("follows", follows, account.pubkey),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
|
||||
// redirect to next step
|
||||
navigate("/auth/onboarding", { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
|
||||
const user = ndk.getUser({ hexpubkey: account.pubkey });
|
||||
const follows = await user.follows();
|
||||
|
||||
// follows as list
|
||||
const followsList = setToArray(follows);
|
||||
|
||||
// update
|
||||
update.mutate(followsList);
|
||||
} catch {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{loading ? "Creating..." : "Continue with"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
|
||||
{status === "loading" ? (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
|
||||
<div>
|
||||
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
|
||||
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<User pubkey={account.pubkey} />
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async () => {
|
||||
const { platform } = await import("@tauri-apps/api/os");
|
||||
return await platform();
|
||||
};
|
||||
|
||||
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
|
||||
const { data: platform } = useSWR("platform", fetcher);
|
||||
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full w-full flex-1 items-center px-2"
|
||||
>
|
||||
<div
|
||||
className={`flex h-full items-center gap-2 ${
|
||||
platform === "darwin" ? "pl-[68px]" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,26 +1,17 @@
|
||||
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
|
||||
import { Link } from "@shared/link";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { User } from "@shared/user";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { useContext } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
export function Page() {
|
||||
export function OnboardingScreen() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [account, fetchAccount] = useActiveAccount((state: any) => [
|
||||
state.account,
|
||||
state.fetch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account === null) {
|
||||
fetchAccount();
|
||||
}
|
||||
}, [fetchAccount]);
|
||||
const { status, account } = useAccount();
|
||||
|
||||
const publish = async () => {
|
||||
try {
|
||||
@ -39,10 +30,7 @@ export function Page() {
|
||||
event.publish();
|
||||
|
||||
// redirect to home
|
||||
setTimeout(
|
||||
() => navigate("/", { overwriteLastHistoryEntry: true }),
|
||||
2000,
|
||||
);
|
||||
navigate("/", { replace: true });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
@ -64,7 +52,7 @@ export function Page() {
|
||||
</div>
|
||||
<div className="w-full border-t border-zinc-800/50 bg-zinc-900 rounded-xl">
|
||||
<div className="h-min w-full px-5 py-3">
|
||||
{account && (
|
||||
{status === "success" && (
|
||||
<User
|
||||
pubkey={account.pubkey}
|
||||
time={Math.floor(Date.now() / 1000)}
|
||||
@ -97,7 +85,7 @@ export function Page() {
|
||||
<ArrowRightCircleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
to="/"
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
|
||||
>
|
||||
Skip for now
|
@ -1,90 +0,0 @@
|
||||
import { Button } from "@shared/button";
|
||||
import { EyeOffIcon, EyeOnIcon } from "@shared/icons";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useMemo, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const createAccount = useActiveAccount((state: any) => state.create);
|
||||
|
||||
const [type, setType] = useState("password");
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(privkey);
|
||||
|
||||
// toggle private key
|
||||
const showPrivateKey = () => {
|
||||
if (type === "password") {
|
||||
setType("text");
|
||||
} else {
|
||||
setType("password");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
createAccount(npub, pubkey, privkey, null, 1);
|
||||
navigate("/app/auth/create/step-2");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Lume is auto-generated key for you
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-base font-semibold text-zinc-400">
|
||||
Public Key
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-base font-semibold text-zinc-400">
|
||||
Private Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{type === "password" ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-100"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
Continue →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,120 +0,0 @@
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { Image } from "@shared/image";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const createTempProfile = useActiveAccount(
|
||||
(state: any) => state.createTempProfile,
|
||||
);
|
||||
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const profile = { ...data, name: data.displayName };
|
||||
createTempProfile(profile);
|
||||
// redirect to step 3
|
||||
navigate("/app/auth/create/step-3", {
|
||||
overwriteLastHistoryEntry: true,
|
||||
});
|
||||
} catch {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Create your profile
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-5">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
type={"hidden"}
|
||||
{...register("picture")}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Avatar
|
||||
</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image
|
||||
src={image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt="avatar"
|
||||
className="relative z-10 h-11 w-11 rounded-md"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Display Name *
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("displayName", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="resize-none relative h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { Button } from "@shared/button";
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { Body, fetch } from "@tauri-apps/api/http";
|
||||
import { useContext, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const [account, tempProfile] = useActiveAccount((state: any) => [
|
||||
state.account,
|
||||
state.tempProfile,
|
||||
]);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const createNIP05 = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch("https://lume.nu/api/user-create", {
|
||||
method: "POST",
|
||||
timeout: 30,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: Body.json({
|
||||
username: username,
|
||||
pubkey: account.pubkey,
|
||||
lightningAddress: "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const profile = { ...tempProfile, nip05: `${username}@lume.nu` };
|
||||
|
||||
const signer = new NDKPrivateKeySigner(account.privkey);
|
||||
ndk.signer = signer;
|
||||
|
||||
const event = new NDKEvent(ndk);
|
||||
// build event
|
||||
event.content = JSON.stringify(profile);
|
||||
event.kind = 0;
|
||||
event.pubkey = account.pubkey;
|
||||
event.tags = [];
|
||||
// publish event
|
||||
event.publish();
|
||||
|
||||
// redirect to step 4
|
||||
navigate("/app/auth/create/step-4", {
|
||||
overwriteLastHistoryEntry: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
console.error("Error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Create your Lume ID
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-800">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoCapitalize="false"
|
||||
autoCorrect="none"
|
||||
spellCheck="false"
|
||||
placeholder="satoshi"
|
||||
className="relative w-full py-3 pl-3.5 !outline-none placeholder:text-zinc-500 bg-transparent text-zinc-100"
|
||||
/>
|
||||
<span className="text-fuchsia-500 font-semibold pr-3.5">
|
||||
@lume.nu
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
preset="large"
|
||||
onClick={() => createNIP05()}
|
||||
disabled={username.length === 0}
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { Resolver, useForm } from "react-hook-form";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
type FormValues = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.key ? values : {},
|
||||
errors: !values.key
|
||||
? {
|
||||
key: {
|
||||
type: "required",
|
||||
message: "This is required.",
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function Page() {
|
||||
const createAccount = useActiveAccount((state: any) => state.create);
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
let privkey = data["key"];
|
||||
|
||||
if (privkey.substring(0, 4) === "nsec") {
|
||||
privkey = nip19.decode(privkey).data;
|
||||
}
|
||||
|
||||
if (typeof getPublicKey(privkey) === "string") {
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
createAccount(npub, pubkey, privkey, null, 1);
|
||||
navigate("/app/auth/import/step-2");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("key", {
|
||||
type: "custom",
|
||||
message: "Private Key is invalid, please check again",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Import your key
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<input
|
||||
{...register("key", { required: true, minLength: 32 })}
|
||||
type={"password"}
|
||||
placeholder="Paste private key here..."
|
||||
className="relative w-full rounded-lg px-3 py-3 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
<span className="text-base text-red-400">
|
||||
{errors.key && <p>{errors.key.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
import { User } from "@app/auth/components/user";
|
||||
import { Button } from "@shared/button";
|
||||
import { LoaderIcon } from "@shared/icons";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { setToArray } from "@utils/transform";
|
||||
import { useContext, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const ndk = useContext(RelayContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [account, updateFollows] = useActiveAccount((state: any) => [
|
||||
state.account,
|
||||
state.updateFollows,
|
||||
]);
|
||||
|
||||
const submit = async () => {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const user = ndk.getUser({ hexpubkey: account.pubkey });
|
||||
const follows = await user.follows();
|
||||
|
||||
// follows as list
|
||||
const followsList = setToArray(follows);
|
||||
|
||||
// update account follows in store
|
||||
updateFollows(followsList);
|
||||
|
||||
// redirect to onboarding
|
||||
setTimeout(
|
||||
() => navigate("/app/onboarding", { overwriteLastHistoryEntry: true }),
|
||||
2000,
|
||||
);
|
||||
} catch {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{loading ? "Creating..." : "Continue with"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
|
||||
{!account ? (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
|
||||
<div>
|
||||
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
|
||||
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<User pubkey={account.pubkey} />
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
"Continue →"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function Page() {
|
||||
export function WelcomeScreen() {
|
||||
return (
|
||||
<div className="w-full h-full grid grid-cols-12 gap-4 px-4 py-4">
|
||||
<div className="col-span-5 border-t border-zinc-800/50 bg-zinc-900 rounded-xl flex flex-col">
|
||||
@ -19,20 +20,20 @@ export function Page() {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-auto w-full flex flex-col gap-2 px-4 py-4">
|
||||
<a
|
||||
href="/app/auth/import"
|
||||
<Link
|
||||
to="/auth/import"
|
||||
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600"
|
||||
>
|
||||
<span className="w-5" />
|
||||
<span>Login with private key</span>
|
||||
<ArrowRightCircleIcon className="w-5 h-5" />
|
||||
</a>
|
||||
<a
|
||||
href="/app/auth/create"
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/create"
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg px-6 font-medium text-zinc-200 bg-zinc-800 hover:bg-zinc-700"
|
||||
>
|
||||
Create new key
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
@ -5,20 +5,24 @@ import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons";
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function ChannelCreateModal() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
|
||||
const { account } = useAccount();
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
@ -36,6 +40,21 @@ export function ChannelCreateModal() {
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const addChannel = useMutation({
|
||||
mutationFn: (event: any) =>
|
||||
createChannel(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.name,
|
||||
event.picture,
|
||||
event.about,
|
||||
event.created_at,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
@ -55,7 +74,12 @@ export function ChannelCreateModal() {
|
||||
event.publish();
|
||||
|
||||
// insert to database
|
||||
createChannel(event.id, event.pubkey, event.content, event.created_at);
|
||||
addChannel.mutate({
|
||||
...event,
|
||||
name: data.name,
|
||||
picture: data.picture,
|
||||
about: data.about,
|
||||
});
|
||||
|
||||
// reset form
|
||||
reset();
|
||||
@ -64,7 +88,7 @@ export function ChannelCreateModal() {
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
// redirect to channel page
|
||||
navigate(`/app/channel?id=${event.id}`);
|
||||
navigate(`/app/channel/${event.id}`);
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
console.log("error: ", e);
|
||||
|
@ -1,29 +1,20 @@
|
||||
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
|
||||
import { Link } from "@shared/link";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function ChannelsListItem({ data }: { data: any }) {
|
||||
const channel: any = useChannelProfile(data.event_id);
|
||||
const pageContext = usePageContext();
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pageID = searchParams.id;
|
||||
|
||||
const channel = useChannelProfile(data.event_id);
|
||||
return (
|
||||
<Link
|
||||
href={`/app/channel?id=${data.event_id}`}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
|
||||
pageID === data.event_id ? "bg-zinc-900 text-zinc-100" : "",
|
||||
)}
|
||||
<NavLink
|
||||
to={`/app/channel/${data.event_id}`}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
|
||||
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900",
|
||||
pageID === data.event_id ? "bg-zinc-800" : "",
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<span className="text-xs text-zinc-100">#</span>
|
||||
</div>
|
||||
<div className="w-full inline-flex items-center justify-between">
|
||||
@ -36,6 +27,6 @@ export function ChannelsListItem({ data }: { data: any }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
@ -1,27 +1,36 @@
|
||||
import { ChannelCreateModal } from "@app/channel/components/createModal";
|
||||
import { ChannelsListItem } from "@app/channel/components/item";
|
||||
import { useChannels } from "@stores/channels";
|
||||
import { useEffect } from "react";
|
||||
import { getChannels } from "@libs/storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function ChannelsList() {
|
||||
const channels = useChannels((state: any) => state.channels);
|
||||
const fetchChannels = useChannels((state: any) => state.fetch);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
const {
|
||||
status,
|
||||
data: channels,
|
||||
isFetching,
|
||||
} = useQuery(
|
||||
["channels"],
|
||||
async () => {
|
||||
return await getChannels();
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{!channels ? (
|
||||
{status === "loading" ? (
|
||||
<>
|
||||
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@ -29,6 +38,12 @@ export function ChannelsList() {
|
||||
<ChannelsListItem key={item.event_id} data={item} />
|
||||
))
|
||||
)}
|
||||
{isFetching && (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
)}
|
||||
<ChannelCreateModal />
|
||||
</div>
|
||||
);
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { Member } from "@app/channel/components/member";
|
||||
import { getChannelUsers } from "@libs/storage";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = ([, id]) => getChannelUsers(id);
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function ChannelMembers({ id }: { id: string }) {
|
||||
const { data, isLoading }: any = useSWR(["channel-members", id], fetcher);
|
||||
const { status, data, isFetching } = useQuery(
|
||||
["channel-members", id],
|
||||
async () => {
|
||||
return await getChannelUsers(id);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
@ -13,8 +16,7 @@ export function ChannelMembers({ id }: { id: string }) {
|
||||
Members
|
||||
</h5>
|
||||
<div className="mt-3 w-full flex flex-wrap gap-1.5">
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{!data ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
data.map((member: { pubkey: string }) => (
|
||||
|
@ -3,14 +3,13 @@ import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { CancelIcon, EnterIcon } from "@shared/icons";
|
||||
import { MediaUploader } from "@shared/mediaUploader";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useChannelMessages } from "@stores/channels";
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
export function ChannelMessageForm({ channelID }: { channelID: string }) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [replyTo, closeReply] = useChannelMessages((state: any) => [
|
||||
@ -18,6 +17,8 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
|
||||
state.closeReply,
|
||||
]);
|
||||
|
||||
const { account } = useAccount();
|
||||
|
||||
const submit = () => {
|
||||
let tags: string[][];
|
||||
|
||||
|
@ -1,23 +1,15 @@
|
||||
import { getChannel, updateChannelMetadata } from "@libs/storage";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useContext } from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const fetcher = async ([, id]) => {
|
||||
const result = await getChannel(id);
|
||||
if (result) {
|
||||
return result;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useContext, useEffect } from "react";
|
||||
|
||||
export function useChannelProfile(id: string) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const { data, mutate } = useSWR(["channel-metadata", id], fetcher);
|
||||
const { data } = useQuery(["channel-metadata", id], async () => {
|
||||
return await getChannel(id);
|
||||
});
|
||||
|
||||
useSWRSubscription(data ? ["channel-metadata", id] : null, () => {
|
||||
useEffect(() => {
|
||||
// subscribe to channel
|
||||
const sub = ndk.subscribe(
|
||||
{
|
||||
@ -32,14 +24,12 @@ export function useChannelProfile(id: string) {
|
||||
sub.addListener("event", (event: { content: string }) => {
|
||||
// update in local database
|
||||
updateChannelMetadata(id, event.content);
|
||||
// revaildate
|
||||
mutate();
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
@ -1,15 +1,20 @@
|
||||
import { ChannelMessageItem } from "../components/messages/item";
|
||||
import { ChannelMessageItem } from "./components/messages/item";
|
||||
import { ChannelMembers } from "@app/channel/components/members";
|
||||
import { ChannelMessageForm } from "@app/channel/components/messages/form";
|
||||
import { ChannelMetadata } from "@app/channel/components/metadata";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useChannelMessages } from "@stores/channels";
|
||||
import { dateToUnix, getHourAgo } from "@utils/date";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { LumeEvent } from "@utils/types";
|
||||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@ -42,13 +47,11 @@ const Empty = (
|
||||
</div>
|
||||
);
|
||||
|
||||
export function Page() {
|
||||
export function ChannelScreen() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const virtuosoRef = useRef(null);
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const channelID = searchParams.id;
|
||||
const { id } = useParams();
|
||||
|
||||
const [messages, fetchMessages, addMessage, clearMessages] =
|
||||
useChannelMessages((state: any) => [
|
||||
@ -58,36 +61,30 @@ export function Page() {
|
||||
state.clear,
|
||||
]);
|
||||
|
||||
useSWRSubscription(
|
||||
channelID ? ["channelMessagesSubscribe", channelID] : null,
|
||||
() => {
|
||||
// subscribe to channel
|
||||
const sub = ndk.subscribe(
|
||||
{
|
||||
"#e": [channelID],
|
||||
kinds: [42],
|
||||
since: dateToUnix(),
|
||||
},
|
||||
{ closeOnEose: false },
|
||||
);
|
||||
|
||||
sub.addListener("event", (event: LumeEvent) => {
|
||||
addMessage(channelID, event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
},
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
fetchMessages(id);
|
||||
}, [fetchMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages(channelID);
|
||||
// subscribe to channel
|
||||
const sub = ndk.subscribe(
|
||||
{
|
||||
"#e": [id],
|
||||
kinds: [42],
|
||||
since: dateToUnix(),
|
||||
},
|
||||
{ closeOnEose: false },
|
||||
);
|
||||
|
||||
sub.addListener("event", (event: LumeEvent) => {
|
||||
addMessage(id, event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearMessages();
|
||||
sub.stop();
|
||||
};
|
||||
}, [fetchMessages]);
|
||||
}, []);
|
||||
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
@ -135,7 +132,7 @@ export function Page() {
|
||||
/>
|
||||
)}
|
||||
<div className="w-full inline-flex shrink-0 px-5 py-3 border-t border-zinc-800">
|
||||
<ChannelMessageForm channelID={channelID} />
|
||||
<ChannelMessageForm channelID={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -146,8 +143,8 @@ export function Page() {
|
||||
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
|
||||
/>
|
||||
<div className="p-3 flex flex-col gap-3">
|
||||
<ChannelMetadata id={channelID} />
|
||||
<ChannelMembers id={channelID} />
|
||||
<ChannelMetadata id={id} />
|
||||
<ChannelMembers id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
@ -1,22 +1,16 @@
|
||||
import { Image } from "@shared/image";
|
||||
import { Link } from "@shared/link";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function ChatsListItem({ data }: { data: any }) {
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
|
||||
const { user, isError, isLoading } = useProfile(data.sender_pubkey);
|
||||
const { status, user, isFetching } = useProfile(data.sender_pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !user ? (
|
||||
{status === "loading" && isFetching ? (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div>
|
||||
@ -24,14 +18,14 @@ export function ChatsListItem({ data }: { data: any }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={`/app/chat?pubkey=${data.sender_pubkey}`}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
|
||||
pagePubkey === data.sender_pubkey
|
||||
? "bg-zinc-900 text-zinc-100"
|
||||
: "",
|
||||
)}
|
||||
<NavLink
|
||||
to={`/app/chat/${data.sender_pubkey}`}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
|
||||
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<Image
|
||||
@ -57,7 +51,7 @@ export function ChatsListItem({ data }: { data: any }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</NavLink>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -1,44 +1,47 @@
|
||||
import { ChatsListItem } from "@app/chat/components/item";
|
||||
import { NewMessageModal } from "@app/chat/components/modal";
|
||||
import { ChatsListSelfItem } from "@app/chat/components/self";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useChats } from "@stores/chats";
|
||||
import { useEffect } from "react";
|
||||
import { getChatsByPubkey } from "@libs/storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
|
||||
export function ChatsList() {
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
const chats = useChats((state: any) => state.chats);
|
||||
const fetchChats = useChats((state: any) => state.fetch);
|
||||
const { account } = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
if (!account) return;
|
||||
fetchChats(account.pubkey);
|
||||
}, [fetchChats]);
|
||||
|
||||
if (!account)
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const {
|
||||
status,
|
||||
data: chats,
|
||||
isFetching,
|
||||
} = useQuery(
|
||||
["chats"],
|
||||
async () => {
|
||||
return await getChatsByPubkey(account.pubkey);
|
||||
},
|
||||
{
|
||||
enabled: account ? true : false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<ChatsListSelfItem data={account} />
|
||||
{!chats ? (
|
||||
{account ? (
|
||||
<ChatsListSelfItem data={account} />
|
||||
) : (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
)}
|
||||
{status === "loading" ? (
|
||||
<>
|
||||
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
@ -50,6 +53,12 @@ export function ChatsList() {
|
||||
}
|
||||
})
|
||||
)}
|
||||
{isFetching && (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
)}
|
||||
<NewMessageModal />
|
||||
</div>
|
||||
);
|
||||
|
@ -1,85 +0,0 @@
|
||||
import { ChatMessageItem } from "@app/chat/components/messages/item";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useChatMessages } from "@stores/chats";
|
||||
import { getHourAgo } from "@utils/date";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const Header = (
|
||||
<div className="relative py-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
|
||||
{getHourAgo(24, now).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Empty = (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-base font-semibold leading-none text-white">
|
||||
Nothing to see here yet
|
||||
</h3>
|
||||
<p className="text-base leading-none text-zinc-400">
|
||||
You two didn't talk yet, let's send first message
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function ChatMessageList() {
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
const messages = useChatMessages((state: any) => state.messages);
|
||||
|
||||
const virtuosoRef = useRef(null);
|
||||
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return (
|
||||
<ChatMessageItem
|
||||
data={messages[index]}
|
||||
userPubkey={account.pubkey}
|
||||
userPrivkey={account.privkey}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[account.privkey, account.pubkey, messages],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return messages[index].id;
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messages}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
components={{
|
||||
Header: () => Header,
|
||||
EmptyPlaceholder: () => Empty,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,16 +2,19 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
import { getPlebs } from "@libs/storage";
|
||||
import { CancelIcon, PlusIcon } from "@shared/icons";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Fragment, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
const fetcher = () => getPlebs();
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function NewMessageModal() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { status, data, isFetching }: any = useQuery(["plebs"], async () => {
|
||||
return await getPlebs();
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data, isLoading }: any = useSWR("plebs", fetcher);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
@ -23,7 +26,7 @@ export function NewMessageModal() {
|
||||
|
||||
const openChat = (npub: string) => {
|
||||
const pubkey = nip19.decode(npub).data;
|
||||
navigate(`/app/chat?pubkey=${pubkey}`);
|
||||
navigate(`/app/chat/${pubkey}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -92,8 +95,7 @@ export function NewMessageModal() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[500px] flex flex-col pb-5 overflow-y-auto">
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{!data ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
data.map((pleb) => (
|
||||
|
@ -1,21 +1,16 @@
|
||||
import { Image } from "@shared/image";
|
||||
import { Link } from "@shared/link";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function ChatsListSelfItem({ data }: { data: any }) {
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
|
||||
const { user, isLoading } = useProfile(data.pubkey);
|
||||
const { status, user, isFetching } = useProfile(data.pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && !user ? (
|
||||
{status === "loading" && isFetching ? (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div>
|
||||
@ -23,12 +18,14 @@ export function ChatsListSelfItem({ data }: { data: any }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={`/app/chat?pubkey=${data.pubkey}`}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
|
||||
pagePubkey === data.pubkey ? "bg-zinc-900 text-zinc-100" : "",
|
||||
)}
|
||||
<NavLink
|
||||
to={`/app/chat/${data.pubkey}`}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
|
||||
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<Image
|
||||
@ -44,7 +41,7 @@ export function ChatsListSelfItem({ data }: { data: any }) {
|
||||
</h5>
|
||||
<span className="text-zinc-500">(you)</span>
|
||||
</div>
|
||||
</Link>
|
||||
</NavLink>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -3,14 +3,15 @@ import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function ChatSidebar({ pubkey }: { pubkey: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
const viewProfile = () => {
|
||||
const pubkey = nip19.decode(user.npub).data;
|
||||
navigate(`/app/user?pubkey=${pubkey}`);
|
||||
navigate(`/app/user/${pubkey}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
110
src/app/chat/index.tsx
Normal file
110
src/app/chat/index.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { ChatMessageForm } from "@app/chat/components/messages/form";
|
||||
import { ChatMessageItem } from "@app/chat/components/messages/item";
|
||||
import { ChatSidebar } from "@app/chat/components/sidebar";
|
||||
import { getChatMessages } from "@libs/storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
export function ChatScreen() {
|
||||
const virtuosoRef = useRef(null);
|
||||
|
||||
const { pubkey } = useParams();
|
||||
const { account } = useAccount();
|
||||
const { status, data } = useQuery(
|
||||
["chat", pubkey],
|
||||
async () => {
|
||||
return await getChatMessages(account.pubkey, pubkey);
|
||||
},
|
||||
{
|
||||
enabled: account ? true : false,
|
||||
},
|
||||
);
|
||||
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return (
|
||||
<ChatMessageItem
|
||||
data={data[index]}
|
||||
userPubkey={account.pubkey}
|
||||
userPrivkey={account.privkey}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-3">
|
||||
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
|
||||
>
|
||||
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
|
||||
</div>
|
||||
<div className="w-full flex-1 p-3">
|
||||
{account && (
|
||||
<div className="flex h-full flex-col justify-between rounded-md bg-zinc-900">
|
||||
{status === "loading" ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
components={{
|
||||
EmptyPlaceholder: () => Empty,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 px-5 p-3 border-t border-zinc-800">
|
||||
<ChatMessageForm
|
||||
receiverPubkey={pubkey}
|
||||
userPubkey={account.pubkey}
|
||||
userPrivkey={account.privkey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
|
||||
/>
|
||||
{pubkey && <ChatSidebar pubkey={pubkey} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Empty = (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-base font-semibold leading-none text-white">
|
||||
Nothing to see here yet
|
||||
</h3>
|
||||
<p className="text-base leading-none text-zinc-400">
|
||||
You two didn't talk yet, let's send first message
|
||||
</p>
|
||||
</div>
|
||||
);
|
@ -1,85 +0,0 @@
|
||||
import { ChatSidebar } from "../components/sidebar";
|
||||
import { ChatMessageList } from "@app/chat/components/messageList";
|
||||
import { ChatMessageForm } from "@app/chat/components/messages/form";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useChatMessages } from "@stores/chats";
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { LumeEvent } from "@utils/types";
|
||||
import { useContext, useEffect } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export function Page() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pubkey = searchParams.pubkey;
|
||||
|
||||
const [add, fetchMessages, clear] = useChatMessages((state: any) => [
|
||||
state.add,
|
||||
state.fetch,
|
||||
state.clear,
|
||||
]);
|
||||
|
||||
useSWRSubscription(account !== pubkey ? ["chat", pubkey] : null, () => {
|
||||
const sub = ndk.subscribe({
|
||||
kinds: [4],
|
||||
authors: [pubkey],
|
||||
"#p": [account.pubkey],
|
||||
since: dateToUnix(),
|
||||
});
|
||||
|
||||
sub.addListener("event", (event: LumeEvent) => {
|
||||
add(account.pubkey, event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages(account.pubkey, pubkey);
|
||||
|
||||
return () => {
|
||||
clear();
|
||||
};
|
||||
}, [pubkey, fetchMessages]);
|
||||
|
||||
if (!account) return <div>Fuck SSR</div>;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-3">
|
||||
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
|
||||
>
|
||||
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
|
||||
</div>
|
||||
<div className="w-full flex-1 p-3">
|
||||
<div className="flex h-full flex-col justify-between rounded-md bg-zinc-900">
|
||||
<ChatMessageList />
|
||||
<div className="shrink-0 px-5 p-3 border-t border-zinc-800">
|
||||
<ChatMessageForm
|
||||
receiverPubkey={pubkey}
|
||||
userPubkey={account.pubkey}
|
||||
userPrivkey={account.privkey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
|
||||
/>
|
||||
<ChatSidebar pubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
17
src/app/error.tsx
Normal file
17
src/app/error.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { useRouteError } from "react-router-dom";
|
||||
|
||||
export function ErrorScreen() {
|
||||
const error: any = useRouteError();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div>
|
||||
<h1>Oops!</h1>
|
||||
<p>Sorry, an unexpected error has occurred.</p>
|
||||
<p>
|
||||
<i>{error.statusText || error.message}</i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1 +0,0 @@
|
||||
export const filesystemRoutingRoot = "/";
|
@ -1,29 +0,0 @@
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useEffect } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const fetchLastLogin = useActiveAccount((state: any) => state.fetchLastLogin);
|
||||
const fetchAccount = useActiveAccount((state: any) => state.fetch);
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
const lastLogin = useActiveAccount((state: any) => state.lastLogin);
|
||||
|
||||
useEffect(() => {
|
||||
if (account === null) {
|
||||
fetchAccount();
|
||||
}
|
||||
if (lastLogin === null) {
|
||||
fetchLastLogin();
|
||||
}
|
||||
if (!account) {
|
||||
navigate("/app/auth", { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
if (account) {
|
||||
navigate("/app/prefetch", { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
}, [fetchAccount, fetchLastLogin, account, lastLogin]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100" />
|
||||
);
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { LayoutOnboarding as Layout } from "./layout";
|
@ -1,11 +0,0 @@
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100"># TODO</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -5,29 +5,25 @@ import {
|
||||
createChat,
|
||||
createNote,
|
||||
getChannels,
|
||||
getLastLogin,
|
||||
} from "@libs/storage";
|
||||
import { NDKFilter } from "@nostr-dev-kit/ndk";
|
||||
import { LumeIcon } from "@shared/icons";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { dateToUnix, getHourAgo } from "@utils/date";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
let totalNotes: number;
|
||||
const totalNotes = await countTotalNotes();
|
||||
const lastLogin = await getLastLogin();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
totalNotes = await countTotalNotes();
|
||||
}
|
||||
|
||||
export function Page() {
|
||||
export function Root() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const now = useRef(new Date());
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [account, lastLogin] = useActiveAccount((state: any) => [
|
||||
state.account,
|
||||
state.lastLogin,
|
||||
]);
|
||||
const { status, account } = useAccount();
|
||||
|
||||
async function fetchNotes() {
|
||||
try {
|
||||
@ -150,12 +146,15 @@ export function Page() {
|
||||
const chats = await fetchChats();
|
||||
const channels = await fetchChannelMessages();
|
||||
if (chats && channels) {
|
||||
navigate("/app/space", { overwriteLastHistoryEntry: true });
|
||||
navigate("/app/space", { replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
prefetch();
|
||||
}, []);
|
||||
|
||||
if (status === "success" && account) {
|
||||
prefetch();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
|
@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
@ -1,19 +1,15 @@
|
||||
import { getNotesByAuthor } from "@libs/storage";
|
||||
import { CancelIcon } from "@shared/icons";
|
||||
import { Note } from "@shared/notes/note";
|
||||
import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { TitleBar } from "@shared/titleBar";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
const TIME = Math.floor(Date.now() / 1000);
|
||||
|
||||
const fetcher = async ([pubkey, offset]) =>
|
||||
getNotesByAuthor(pubkey, TIME, ITEM_PER_PAGE, offset);
|
||||
|
||||
export function FeedBlock({ params }: { params: any }) {
|
||||
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
|
||||
|
||||
@ -21,26 +17,36 @@ export function FeedBlock({ params }: { params: any }) {
|
||||
removeBlock(params.id, true);
|
||||
};
|
||||
|
||||
const getKey = (pageIndex, previousPageData) => {
|
||||
if (previousPageData && !previousPageData.data) return null;
|
||||
if (pageIndex === 0) return [params.content, 0];
|
||||
return [params.content, previousPageData.nextCursor];
|
||||
};
|
||||
|
||||
const { data, isLoading, size, setSize } = useSWRInfinite(getKey, fetcher);
|
||||
|
||||
const notes = useMemo(
|
||||
() => (data ? data.flatMap((d) => d.data) : []),
|
||||
[data],
|
||||
);
|
||||
const {
|
||||
status,
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
}: any = useInfiniteQuery({
|
||||
queryKey: ["newsfeed", params.content],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotesByAuthor(
|
||||
params.content,
|
||||
TIME,
|
||||
ITEM_PER_PAGE,
|
||||
pageParam,
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: notes.length,
|
||||
count: hasNextPage ? notes.length + 1 : notes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
estimateSize: () => 500,
|
||||
overscan: 2,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
|
||||
useEffect(() => {
|
||||
@ -50,10 +56,25 @@ export function FeedBlock({ params }: { params: any }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastItem.index >= notes.length - 1) {
|
||||
setSize(size + 1);
|
||||
if (
|
||||
lastItem.index >= notes.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [notes.length, rowVirtualizer.getVirtualItems()]);
|
||||
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
|
||||
|
||||
const renderItem = (index: string | number) => {
|
||||
const note = notes[index];
|
||||
|
||||
if (!note) return;
|
||||
return (
|
||||
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
|
||||
<Note event={note} block={params.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 w-[400px] border-r border-zinc-900">
|
||||
@ -63,7 +84,7 @@ export function FeedBlock({ params }: { params: any }) {
|
||||
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
|
||||
style={{ contain: "strict" }}
|
||||
>
|
||||
{!data || isLoading ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
@ -85,20 +106,9 @@ export function FeedBlock({ params }: { params: any }) {
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const note = notes[virtualRow.index];
|
||||
if (note) {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<Note event={note} block={params.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{rowVirtualizer
|
||||
.getVirtualItems()
|
||||
.map((virtualRow) => renderItem(virtualRow.index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,68 +1,44 @@
|
||||
import { createNote, getNotes } from "@libs/storage";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { NDKEvent, NDKFilter, NDKSubscription } from "@nostr-dev-kit/ndk";
|
||||
import { Note } from "@shared/notes/note";
|
||||
import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { TitleBar } from "@shared/titleBar";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
const TIME = Math.floor(Date.now() / 1000);
|
||||
|
||||
const fetcher = async ([, offset]) => getNotes(TIME, ITEM_PER_PAGE, offset);
|
||||
|
||||
export function FollowingBlock({ block }: { block: number }) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
|
||||
const getKey = (pageIndex, previousPageData) => {
|
||||
if (previousPageData && !previousPageData.data) return null;
|
||||
if (pageIndex === 0) return ["following", 0];
|
||||
return ["following", previousPageData.nextCursor];
|
||||
};
|
||||
const { account } = useAccount();
|
||||
|
||||
// fetch initial notes
|
||||
const { data, isLoading, size, setSize } = useSWRInfinite(getKey, fetcher);
|
||||
// fetch live notes
|
||||
useSWRSubscription(account ? "eventCollector" : null, () => {
|
||||
const follows = JSON.parse(account.follows);
|
||||
const sub = ndk.subscribe({
|
||||
kinds: [1, 6],
|
||||
authors: follows,
|
||||
since: dateToUnix(),
|
||||
});
|
||||
|
||||
sub.addListener("event", (event: NDKEvent) => {
|
||||
// save note
|
||||
createNote(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
const {
|
||||
status,
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
}: any = useInfiniteQuery({
|
||||
queryKey: ["newsfeed-circle"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const notes = useMemo(
|
||||
() => (data ? data.flatMap((d) => d.data) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: notes.length,
|
||||
count: hasNextPage ? notes.length + 1 : notes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 500,
|
||||
overscan: 2,
|
||||
@ -77,10 +53,43 @@ export function FollowingBlock({ block }: { block: number }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastItem.index >= notes.length - 1) {
|
||||
setSize(size + 1);
|
||||
if (
|
||||
lastItem.index >= notes.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [notes.length, rowVirtualizer.getVirtualItems()]);
|
||||
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
|
||||
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription;
|
||||
|
||||
if (account) {
|
||||
const follows = JSON.parse(account.follows);
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1, 6],
|
||||
authors: follows,
|
||||
since: dateToUnix(),
|
||||
};
|
||||
|
||||
sub = ndk.subscribe(filter);
|
||||
sub.addListener("event", (event: NDKEvent) => {
|
||||
createNote(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
const renderItem = (index: string | number) => {
|
||||
const note = notes[index];
|
||||
@ -101,7 +110,7 @@ export function FollowingBlock({ block }: { block: number }) {
|
||||
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
|
||||
style={{ contain: "strict" }}
|
||||
>
|
||||
{!data || isLoading ? (
|
||||
{status === "loading" ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
@ -129,6 +138,13 @@ export function FollowingBlock({ block }: { block: number }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && !isFetchingNextPage && (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { getNoteByID } from "@libs/storage";
|
||||
import { ArrowLeftIcon } from "@shared/icons";
|
||||
import { Kind1 } from "@shared/notes/contents/kind1";
|
||||
import { Kind1063 } from "@shared/notes/contents/kind1063";
|
||||
import { NoteMetadata } from "@shared/notes/metadata";
|
||||
@ -9,15 +8,19 @@ import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { TitleBar } from "@shared/titleBar";
|
||||
import { User } from "@shared/user";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { parser } from "@utils/parser";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = ([, id]) => getNoteByID(id);
|
||||
|
||||
export function ThreadBlock({ params }: { params: any }) {
|
||||
const { data } = useSWR(["thread", params.content], fetcher);
|
||||
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
|
||||
const { status, data, isFetching } = useQuery(
|
||||
["thread", params.content],
|
||||
async () => {
|
||||
return await getNoteByID(params.content);
|
||||
},
|
||||
);
|
||||
|
||||
const content = data ? parser(data) : null;
|
||||
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
|
||||
|
||||
const close = () => {
|
||||
removeBlock(params.id, false);
|
||||
@ -27,7 +30,7 @@ export function ThreadBlock({ params }: { params: any }) {
|
||||
<div className="shrink-0 w-[400px] border-r border-zinc-900">
|
||||
<TitleBar title={params.title} onClick={() => close()} />
|
||||
<div className="scrollbar-hide flex w-full h-full flex-col gap-1.5 pt-1.5 pb-20 overflow-y-auto">
|
||||
{!data ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
|
@ -3,18 +3,11 @@ import { FeedBlock } from "@app/space/components/blocks/feed";
|
||||
import { FollowingBlock } from "@app/space/components/blocks/following";
|
||||
import { ImageBlock } from "@app/space/components/blocks/image";
|
||||
import { ThreadBlock } from "@app/space/components/blocks/thread";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useEffect } from "react";
|
||||
import { getBlocks } from "@libs/storage";
|
||||
|
||||
export function Page() {
|
||||
const blocks = useActiveAccount((state: any) => state.blocks);
|
||||
const fetchBlocks = useActiveAccount((state: any) => state.fetchBlocks);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocks !== null) return;
|
||||
fetchBlocks();
|
||||
}, [fetchBlocks]);
|
||||
const blocks = await getBlocks();
|
||||
|
||||
export function SpaceScreen() {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
|
||||
<FollowingBlock block={1} />
|
@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
@ -1,16 +1,20 @@
|
||||
import { Image } from "@shared/image";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { compactNumber } from "@utils/number";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function Profile({ data }: { data: any }) {
|
||||
const { data: userStats, error } = useSWR(
|
||||
`https://api.nostr.band/v0/stats/profile/${data.pubkey}`,
|
||||
fetcher,
|
||||
);
|
||||
const {
|
||||
status,
|
||||
data: userStats,
|
||||
isFetching,
|
||||
} = useQuery(["user-stats", data.pubkey], async () => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${data.pubkey}`,
|
||||
);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
const embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
|
||||
const profile = embedProfile;
|
||||
@ -47,8 +51,7 @@ export function Profile({ data }: { data: any }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{error && <p>Failed to fetch user stats</p>}
|
||||
{!userStats ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="w-full flex items-center gap-8">
|
||||
|
@ -1,22 +1,22 @@
|
||||
import { Note } from "@shared/notes/note";
|
||||
import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { TitleBar } from "@shared/titleBar";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function TrendingNotes() {
|
||||
const { data, error } = useSWR(
|
||||
"https://api.nostr.band/v0/trending/notes",
|
||||
fetcher,
|
||||
const { status, data, isFetching } = useQuery(
|
||||
["trending-notes"],
|
||||
async () => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/notes");
|
||||
return res.json();
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
|
||||
<TitleBar title="Trending Posts" />
|
||||
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto">
|
||||
{error && <p>Failed to load...</p>}
|
||||
{!data ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
|
@ -1,22 +1,22 @@
|
||||
import { Profile } from "@app/trending/components/profile";
|
||||
import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { TitleBar } from "@shared/titleBar";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function TrendingProfiles() {
|
||||
const { data, error } = useSWR(
|
||||
"https://api.nostr.band/v0/trending/profiles",
|
||||
fetcher,
|
||||
const { status, data, isFetching } = useQuery(
|
||||
["trending-profiles"],
|
||||
async () => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles");
|
||||
return res.json();
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
|
||||
<TitleBar title="Trending Profiles" />
|
||||
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto">
|
||||
{error && <p>Failed to load...</p>}
|
||||
{!data ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { TrendingNotes } from "@app/trending/components/trendingNotes";
|
||||
import { TrendingProfiles } from "@app/trending/components/trendingProfiles";
|
||||
|
||||
export function Page() {
|
||||
export function TrendingScreen() {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
|
||||
<TrendingProfiles />
|
@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
@ -3,27 +3,30 @@ import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { compactNumber } from "@utils/number";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { useContext } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function Page() {
|
||||
export function UserScreen() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pubkey = searchParams.pubkey || "";
|
||||
|
||||
const { user } = useProfile(pubkey);
|
||||
const { data: userStats, error } = useSWR(
|
||||
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
|
||||
fetcher,
|
||||
);
|
||||
const { data: userStats, error } = useQuery(["user", pubkey], async () => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
return await res.json();
|
||||
}
|
||||
});
|
||||
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
const follows = account ? JSON.parse(account.follows) : [];
|
||||
@ -180,12 +183,12 @@ export function Page() {
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
<Link
|
||||
to={`/app/chat/${pubkey}`}
|
||||
className="inline-flex w-44 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -18,8 +18,14 @@ export async function connect(): Promise<Database> {
|
||||
// get active account
|
||||
export async function getActiveAccount() {
|
||||
const db = await connect();
|
||||
const result = await db.select("SELECT * FROM accounts WHERE is_active = 1;");
|
||||
return result[0];
|
||||
const result: any = await db.select(
|
||||
"SELECT * FROM accounts WHERE is_active = 1;",
|
||||
);
|
||||
if (result.length > 0) {
|
||||
return result[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// get all accounts
|
||||
@ -189,9 +195,10 @@ export async function createNote(
|
||||
// get note replies
|
||||
export async function getReplies(parent_id: string) {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
const result: any = await db.select(
|
||||
`SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// create reply note
|
||||
@ -214,7 +221,10 @@ export async function createReplyNote(
|
||||
// get all channels
|
||||
export async function getChannels() {
|
||||
const db = await connect();
|
||||
return await db.select("SELECT * FROM channels ORDER BY created_at DESC;");
|
||||
const result: any = await db.select(
|
||||
"SELECT * FROM channels ORDER BY created_at DESC;",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// get channel by id
|
||||
@ -230,13 +240,15 @@ export async function getChannel(id: string) {
|
||||
export async function createChannel(
|
||||
event_id: string,
|
||||
pubkey: string,
|
||||
metadata: string,
|
||||
name: string,
|
||||
picture: string,
|
||||
about: string,
|
||||
created_at: number,
|
||||
) {
|
||||
const db = await connect();
|
||||
return await db.execute(
|
||||
"INSERT OR IGNORE INTO channels (event_id, pubkey, metadata, created_at) VALUES (?, ?, ?, ?);",
|
||||
[event_id, pubkey, metadata, created_at],
|
||||
"INSERT OR IGNORE INTO channels (event_id, pubkey, name, picture, about, created_at) VALUES (?, ?, ?, ?, ?, ?);",
|
||||
[event_id, pubkey, name, picture, about, created_at],
|
||||
);
|
||||
}
|
||||
|
||||
@ -279,17 +291,19 @@ export async function getChannelMessages(channel_id: string) {
|
||||
// get channel users
|
||||
export async function getChannelUsers(channel_id: string) {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
const result: any = await db.select(
|
||||
`SELECT DISTINCT pubkey FROM channel_messages WHERE channel_id = "${channel_id}";`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// get all chats by pubkey
|
||||
export async function getChatsByPubkey(pubkey: string) {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
const result: any = await db.select(
|
||||
`SELECT DISTINCT sender_pubkey FROM chats WHERE receiver_pubkey = "${pubkey}" ORDER BY created_at DESC;`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// get chat messages
|
||||
@ -390,11 +404,13 @@ export async function updateItemInBlacklist(content: string, status: number) {
|
||||
}
|
||||
|
||||
// get all blocks
|
||||
export async function getBlocks(account_id: number) {
|
||||
export async function getBlocks() {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
`SELECT * FROM blocks WHERE account_id <= "${account_id}";`,
|
||||
const activeAccount = await getActiveAccount();
|
||||
const result: any = await db.select(
|
||||
`SELECT * FROM blocks WHERE account_id <= "${activeAccount.id}";`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// create block
|
||||
|
6
src/main.tsx
Normal file
6
src/main.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import App from "./app";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
@ -1,40 +0,0 @@
|
||||
import "./index.css";
|
||||
import { Shell } from "./shell";
|
||||
import { PageContextClient } from "./types";
|
||||
import { StrictMode } from "react";
|
||||
import { Root, createRoot, hydrateRoot } from "react-dom/client";
|
||||
import "vidstack/styles/defaults.css";
|
||||
|
||||
export const clientRouting = true;
|
||||
export const hydrationCanBeAborted = true;
|
||||
|
||||
let root: Root;
|
||||
|
||||
export async function render(pageContext: PageContextClient) {
|
||||
const { Page, pageProps } = pageContext;
|
||||
|
||||
if (!Page)
|
||||
throw new Error(
|
||||
"Client-side render() hook expects pageContext.Page to be defined",
|
||||
);
|
||||
|
||||
const page = (
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
const container = document.getElementById("app");
|
||||
// SPA
|
||||
if (container.innerHTML === "" || !pageContext.isHydration) {
|
||||
if (!root) {
|
||||
root = createRoot(container);
|
||||
}
|
||||
root.render(page);
|
||||
// SSR
|
||||
} else {
|
||||
root = hydrateRoot(container, page);
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import { Shell } from "./shell";
|
||||
import { PageContextServer } from "./types";
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { dangerouslySkipEscape, escapeInject } from "vite-plugin-ssr/server";
|
||||
|
||||
export const passToClient = ["pageProps"];
|
||||
|
||||
export function render(pageContext: PageContextServer) {
|
||||
let pageHtml: string;
|
||||
|
||||
if (!pageContext.Page) {
|
||||
// SPA
|
||||
pageHtml = "";
|
||||
} else {
|
||||
// SSR / HTML-only
|
||||
const { Page, pageProps } = pageContext;
|
||||
if (!Page)
|
||||
throw new Error(
|
||||
"My render() hook expects pageContext.Page to be defined",
|
||||
);
|
||||
|
||||
pageHtml = ReactDOMServer.renderToString(
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
<html lang="en" class="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body class="cursor-default select-none overflow-hidden font-sans antialiased bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
||||
<div id="app">${dangerouslySkipEscape(pageHtml)}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
export function Page({ is404 }: { is404: boolean }) {
|
||||
if (is404) {
|
||||
return (
|
||||
<>
|
||||
<h1>404 Page Not Found</h1>
|
||||
<p>This page could not be found.</p>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>Something went wrong.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
export function LayoutDefault({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import { LayoutDefault } from "./layoutDefault";
|
||||
import { PageContext } from "./types";
|
||||
import { updateLastLogin } from "@libs/storage";
|
||||
import { RelayProvider } from "@shared/relayProvider";
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { PageContextProvider } from "@utils/hooks/usePageContext";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Shell({
|
||||
children,
|
||||
pageContext,
|
||||
}: { children: React.ReactNode; pageContext: PageContext }) {
|
||||
const Layout =
|
||||
(pageContext.exports.Layout as React.ElementType) ||
|
||||
(LayoutDefault as React.ElementType);
|
||||
|
||||
useEffect(() => {
|
||||
async function initWindowEvent() {
|
||||
const { TauriEvent } = await import("@tauri-apps/api/event");
|
||||
const { appWindow, getCurrent } = await import("@tauri-apps/api/window");
|
||||
|
||||
// listen window close event
|
||||
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
|
||||
// update last login time
|
||||
updateLastLogin(dateToUnix());
|
||||
// close window
|
||||
appWindow.close();
|
||||
});
|
||||
}
|
||||
|
||||
initWindowEvent().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContextProvider pageContext={pageContext}>
|
||||
<RelayProvider>
|
||||
<Layout>{children}</Layout>
|
||||
</RelayProvider>
|
||||
</PageContextProvider>
|
||||
);
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import type {
|
||||
PageContextBuiltIn,
|
||||
/*
|
||||
// When using Client Routing https://vite-plugin-ssr.com/clientRouting
|
||||
PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient
|
||||
/*/
|
||||
// When using Server Routing
|
||||
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
|
||||
} from "vite-plugin-ssr/types";
|
||||
|
||||
export type { PageContextServer };
|
||||
export type { PageContextClient };
|
||||
export type { PageContext };
|
||||
export type { PageProps };
|
||||
|
||||
type Page = (pageProps: PageProps) => React.ReactElement;
|
||||
type PageProps = Record<string, never>;
|
||||
|
||||
export type PageContextCustom = {
|
||||
Page: Page;
|
||||
pageProps?: PageProps;
|
||||
redirectTo?: string;
|
||||
urlPathname: string;
|
||||
exports: {
|
||||
documentProps?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PageContextServer = PageContextBuiltIn<Page> & PageContextCustom;
|
||||
type PageContextClient = PageContextBuiltInClient<Page> & PageContextCustom;
|
||||
|
||||
type PageContext = PageContextClient | PageContextServer;
|
@ -1,26 +1,25 @@
|
||||
import { getLastLogin } from "@libs/storage";
|
||||
import { Image } from "@shared/image";
|
||||
import { NetworkStatusIndicator } from "@shared/networkStatusIndicator";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useActiveAccount } from "@stores/accounts";
|
||||
import { useChannels } from "@stores/channels";
|
||||
import { useChatMessages, useChats } from "@stores/chats";
|
||||
import { useChats } from "@stores/chats";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { sendNativeNotification } from "@utils/notification";
|
||||
import { useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
import { useContext, useEffect } from "react";
|
||||
|
||||
const lastLogin = await getLastLogin();
|
||||
|
||||
export function ActiveAccount({ data }: { data: any }) {
|
||||
const ndk = useContext(RelayContext);
|
||||
|
||||
const lastLogin = useActiveAccount((state: any) => state.lastLogin);
|
||||
const notifyChat = useChats((state: any) => state.add);
|
||||
const saveChat = useChatMessages((state: any) => state.add);
|
||||
const notifyChannel = useChannels((state: any) => state.add);
|
||||
|
||||
const { user } = useProfile(data.pubkey);
|
||||
const { status, user } = useProfile(data.pubkey);
|
||||
|
||||
useSWRSubscription(user ? ["activeAccount", data.pubkey] : null, () => {
|
||||
useEffect(() => {
|
||||
const since = lastLogin > 0 ? lastLogin : Math.floor(Date.now() / 1000);
|
||||
// subscribe to channel
|
||||
const sub = ndk.subscribe(
|
||||
@ -41,8 +40,6 @@ export function ActiveAccount({ data }: { data: any }) {
|
||||
sendNativeNotification("Someone mention you");
|
||||
break;
|
||||
case 4:
|
||||
// save
|
||||
saveChat(data.pubkey, event);
|
||||
// update state
|
||||
notifyChat(event.pubkey);
|
||||
// send native notifiation
|
||||
@ -62,16 +59,20 @@ export function ActiveAccount({ data }: { data: any }) {
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button type="button" className="relative inline-block h-9 w-9">
|
||||
<Image
|
||||
src={user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={data.npub}
|
||||
className="h-9 w-9 rounded object-cover"
|
||||
/>
|
||||
{status === "loading" ? (
|
||||
<div className="w-9 h-9 rounded bg-zinc-800 animate-pulse" />
|
||||
) : (
|
||||
<Image
|
||||
src={user.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={data.npub}
|
||||
className="h-9 w-9 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<NetworkStatusIndicator />
|
||||
</button>
|
||||
);
|
||||
|
@ -1,27 +0,0 @@
|
||||
import { Link } from "@shared/link";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function ActiveLink({
|
||||
href,
|
||||
className,
|
||||
activeClassName,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
className: string;
|
||||
activeClassName: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pageContext = usePageContext();
|
||||
const pathName = pageContext.urlPathname;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={twMerge(className, href === pathName ? activeClassName : "")}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function AppHeader({ reverse }: { reverse?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
navigate(1);
|
||||
};
|
||||
|
||||
return (
|
||||
|
16
src/shared/appLayout.tsx
Normal file
16
src/shared/appLayout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Navigation } from "@shared/navigation";
|
||||
import { Outlet, ScrollRestoration } from "react-router-dom";
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="flex w-screen h-screen">
|
||||
<div className="relative flex flex-row shrink-0">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="w-full h-full">
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,20 +1,18 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons";
|
||||
import useSWR from "swr";
|
||||
import { platform } from "@tauri-apps/api/os";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
|
||||
const fetcher = async () => {
|
||||
const { platform } = await import("@tauri-apps/api/os");
|
||||
return await platform();
|
||||
};
|
||||
const platformName = await platform();
|
||||
|
||||
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
|
||||
const { data: platform } = useSWR("platform", fetcher);
|
||||
export function AuthLayout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
navigate(1);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -22,7 +20,7 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
@ -30,7 +28,7 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
<div
|
||||
className={`flex h-full items-center gap-2 ${
|
||||
platform === "darwin" ? "pl-[68px]" : ""
|
||||
platformName === "darwin" ? "pl-[68px]" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
@ -58,7 +56,9 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -15,7 +15,8 @@ import { Fragment } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
export function Composer() {
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
const account = useActiveAccount((state) => state.account);
|
||||
|
||||
const [toggle, open] = useComposer((state: any) => [
|
||||
state.toggleModal,
|
||||
state.open,
|
||||
|
@ -1,23 +0,0 @@
|
||||
import { Navigation } from "@shared/navigation";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async () => {
|
||||
const { platform } = await import("@tauri-apps/api/os");
|
||||
return platform();
|
||||
};
|
||||
|
||||
export function DefaultLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: platform } = useSWR(
|
||||
typeof window !== "undefined" ? "platform" : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-screen h-screen">
|
||||
<div className="relative flex flex-row shrink-0">
|
||||
<Navigation reverse={platform !== "darwin"} />
|
||||
</div>
|
||||
<div className="w-full h-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Link({
|
||||
href,
|
||||
className,
|
||||
children,
|
||||
}: { href: string; className?: string; children: ReactNode }) {
|
||||
const goto = () => {
|
||||
navigate(href, { keepScrollPosition: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => goto()} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
@ -1,18 +1,19 @@
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { getAccounts, getActiveAccount } from "@libs/storage";
|
||||
import { getActiveAccount } from "@libs/storage";
|
||||
import { ActiveAccount } from "@shared/accounts/active";
|
||||
import { InactiveAccount } from "@shared/accounts/inactive";
|
||||
import { PlusIcon, VerticalDotsIcon } from "@shared/icons";
|
||||
import { Link } from "@shared/link";
|
||||
import { VerticalDotsIcon } from "@shared/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
const allFetcher = () => getAccounts();
|
||||
const fetcher = () => getActiveAccount();
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function MultiAccounts() {
|
||||
const { data: accounts }: any = useSWR("allAccounts", allFetcher);
|
||||
const { data: activeAccount }: any = useSWR("activeAccount", fetcher);
|
||||
const {
|
||||
status,
|
||||
data: activeAccount,
|
||||
isFetching,
|
||||
} = useQuery(["activeAccount"], async () => {
|
||||
return await getActiveAccount();
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@ -24,28 +25,11 @@ export function MultiAccounts() {
|
||||
<div className="flex flex-col gap-2 rounded-xl p-2 border-t border-zinc-800/50 bg-zinc-900/80 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{!activeAccount ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<div className="group relative flex h-9 w-9 shrink animate-pulse items-center justify-center rounded-lg bg-zinc-900" />
|
||||
) : (
|
||||
<ActiveAccount data={activeAccount} />
|
||||
)}
|
||||
{!accounts ? (
|
||||
<div className="group relative flex h-9 w-9 shrink animate-pulse items-center justify-center rounded-lg bg-zinc-900" />
|
||||
) : (
|
||||
accounts.map((account: { is_active: number; pubkey: string }) => (
|
||||
<InactiveAccount key={account.pubkey} data={account} />
|
||||
))
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="group relative flex h-9 w-9 shrink items-center justify-center rounded border border-dashed border-zinc-600 hover:border-zinc-400"
|
||||
>
|
||||
<PlusIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-zinc-100"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@ -66,13 +50,13 @@ export function MultiAccounts() {
|
||||
className="flex flex-col items-start justify-start gap-1 pt-1.5 border-t border-zinc-800 transform"
|
||||
>
|
||||
<Link
|
||||
href="/app/settings"
|
||||
to="/app/settings"
|
||||
className="w-full py-2 px-2 rounded hover:bg-zinc-800 text-zinc-100 text-start text-sm"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<Link
|
||||
href="/app/logout"
|
||||
to="/app/logout"
|
||||
className="w-full py-2 px-2 rounded hover:bg-zinc-800 text-zinc-100 text-start text-sm"
|
||||
>
|
||||
Logout
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { ChannelsList } from "@app/channel/components/list";
|
||||
import { ChatsList } from "@app/chat/components/list";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import { ActiveLink } from "@shared/activeLink";
|
||||
import { AppHeader } from "@shared/appHeader";
|
||||
import { Composer } from "@shared/composer/modal";
|
||||
import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from "@shared/icons";
|
||||
import { MultiAccounts } from "@shared/multiAccounts";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function Navigation({ reverse }: { reverse?: boolean }) {
|
||||
export function Navigation({ reverse = false }: { reverse?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-[232px] flex-col gap-3 ${
|
||||
@ -27,20 +28,28 @@ export function Navigation({ reverse }: { reverse?: boolean }) {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<ActiveLink
|
||||
href="/app/space"
|
||||
className="flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200"
|
||||
activeClassName="bg-zinc-900/50"
|
||||
<NavLink
|
||||
to="/app/space"
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
"flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200",
|
||||
isActive ? "bg-zinc-900/50" : "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<SpaceIcon width={12} height={12} className="text-zinc-100" />
|
||||
</span>
|
||||
<span className="font-medium">Spaces</span>
|
||||
</ActiveLink>
|
||||
<ActiveLink
|
||||
href="/app/trending"
|
||||
className="flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200"
|
||||
activeClassName="bg-zinc-900/50"
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/app/trending"
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
"flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200",
|
||||
isActive ? "bg-zinc-900/50" : "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<TrendingIcon
|
||||
@ -50,7 +59,7 @@ export function Navigation({ reverse }: { reverse?: boolean }) {
|
||||
/>
|
||||
</span>
|
||||
<span className="font-medium">Trending</span>
|
||||
</ActiveLink>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
{/* Channels */}
|
||||
|
@ -3,18 +3,19 @@ import { Kind1063 } from "@shared/notes/contents/kind1063";
|
||||
import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { User } from "@shared/user";
|
||||
import { useEvent } from "@utils/hooks/useEvent";
|
||||
import { parser } from "@utils/parser";
|
||||
import { memo } from "react";
|
||||
|
||||
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
const data = useEvent(id);
|
||||
const { status, data, isFetching } = useEvent(id);
|
||||
|
||||
const kind1 = data?.kind === 1 ? parser(data) : null;
|
||||
const kind1 = data?.kind === 1 ? data.content : null;
|
||||
const kind1063 = data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-zinc-800 px-3 py-3">
|
||||
{data ? (
|
||||
{isFetching || status === "loading" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<User pubkey={data.pubkey} time={data.created_at} size="small" />
|
||||
<div className="mt-2">
|
||||
@ -37,8 +38,6 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { Link } from "@shared/link";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
to={`/app/user/${pubkey}`}
|
||||
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal"
|
||||
>
|
||||
@{user?.name || user?.displayName || shortenKey(pubkey)}
|
||||
|
@ -5,57 +5,9 @@ import { NoteReply } from "@shared/notes/metadata/reply";
|
||||
import { NoteRepost } from "@shared/notes/metadata/repost";
|
||||
import { NoteZap } from "@shared/notes/metadata/zap";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { decode } from "light-bolt11-decoder";
|
||||
import { useContext } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async ([, ndk, id]) => {
|
||||
let replies = 0;
|
||||
let reposts = 0;
|
||||
let zap = 0;
|
||||
|
||||
const filter: NDKFilter = {
|
||||
"#e": [id],
|
||||
kinds: [1, 6, 9735],
|
||||
};
|
||||
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
events.forEach((event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
replies += 1;
|
||||
createReplyNote(
|
||||
id,
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
);
|
||||
break;
|
||||
case 6:
|
||||
reposts += 1;
|
||||
break;
|
||||
case 9735: {
|
||||
const bolt11 = event.tags.find((tag) => tag[0] === "bolt11")[1];
|
||||
if (bolt11) {
|
||||
const decoded = decode(bolt11);
|
||||
const amount = decoded.sections.find(
|
||||
(item) => item.name === "amount",
|
||||
);
|
||||
const sats = amount.value / 1000;
|
||||
zap += sats;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return { replies, reposts, zap };
|
||||
};
|
||||
|
||||
export function NoteMetadata({
|
||||
id,
|
||||
@ -67,11 +19,60 @@ export function NoteMetadata({
|
||||
currentBlock?: number;
|
||||
}) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const { data, isLoading } = useSWR(["note-metadata", ndk, id], fetcher);
|
||||
const { status, data, isFetching } = useQuery(
|
||||
["note-metadata", id],
|
||||
async () => {
|
||||
let replies = 0;
|
||||
let reposts = 0;
|
||||
let zap = 0;
|
||||
|
||||
const filter: NDKFilter = {
|
||||
"#e": [id],
|
||||
kinds: [1, 6, 9735],
|
||||
};
|
||||
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
events.forEach((event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
replies += 1;
|
||||
createReplyNote(
|
||||
id,
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
);
|
||||
break;
|
||||
case 6:
|
||||
reposts += 1;
|
||||
break;
|
||||
case 9735: {
|
||||
const bolt11 = event.tags.find((tag) => tag[0] === "bolt11")[1];
|
||||
if (bolt11) {
|
||||
const decoded = decode(bolt11);
|
||||
const amount = decoded.sections.find(
|
||||
(item) => item.name === "amount",
|
||||
);
|
||||
const sats = amount.value / 1000;
|
||||
zap += sats;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return { replies, reposts, zap };
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center w-full h-12 mt-2">
|
||||
{!data || isLoading ? (
|
||||
{status === "loading" || isFetching ? (
|
||||
<>
|
||||
<div className="w-20 group inline-flex items-center gap-1.5">
|
||||
<ReplyIcon
|
||||
|
@ -4,21 +4,22 @@ import { NoteMetadata } from "@shared/notes/metadata";
|
||||
import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { User } from "@shared/user";
|
||||
import { useEvent } from "@utils/hooks/useEvent";
|
||||
import { parser } from "@utils/parser";
|
||||
|
||||
export function NoteParent({
|
||||
id,
|
||||
currentBlock,
|
||||
}: { id: string; currentBlock: number }) {
|
||||
const data = useEvent(id);
|
||||
const { status, data, isFetching } = useEvent(id);
|
||||
|
||||
const kind1 = data?.kind === 1 ? parser(data) : null;
|
||||
const kind1 = data?.kind === 1 ? data.content : null;
|
||||
const kind1063 = data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden flex flex-col pb-6">
|
||||
<div className="absolute left-[18px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
|
||||
{data ? (
|
||||
{isFetching || status === "loading" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-5 pl-[49px]">
|
||||
@ -46,8 +47,6 @@ export function NoteParent({
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,16 +1,26 @@
|
||||
import { Image } from "@shared/image";
|
||||
import { useOpenGraph } from "@utils/hooks/useOpenGraph";
|
||||
|
||||
function isValidURL(string: string) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
const domain = new URL(urls[0]);
|
||||
const { data, error, isLoading } = useOpenGraph(urls[0]);
|
||||
const { status, data, error, isFetching } = useOpenGraph(urls[0]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
|
||||
{error && <p>failed to load</p>}
|
||||
{isLoading || !data ? (
|
||||
{isFetching || status === "loading" ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full h-16 bg-zinc-700 animate-pulse" />
|
||||
<div className="w-full h-44 bg-zinc-700 animate-pulse" />
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<div className="w-2/3 h-3 rounded bg-zinc-700 animate-pulse" />
|
||||
<div className="w-3/4 h-3 rounded bg-zinc-700 animate-pulse" />
|
||||
@ -19,6 +29,20 @@ export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<a
|
||||
className="flex flex-col px-3 py-3 rounded-lg border border-transparent hover:border-fuchsia-900"
|
||||
href={urls[0]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<p className="leading-none text-sm text-zinc-400 line-clamp-3">
|
||||
Can't fetch open graph, click to open website directly
|
||||
</p>
|
||||
<span className="mt-2.5 leading-none text-sm text-zinc-500">
|
||||
{domain.hostname}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
className="flex flex-col rounded-lg border border-transparent hover:border-fuchsia-900"
|
||||
@ -26,13 +50,20 @@ export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{data["og:image"] && (
|
||||
{isValidURL(data["og:image"]) ? (
|
||||
<Image
|
||||
src={data["og:image"]}
|
||||
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
|
||||
alt={urls[0]}
|
||||
className="w-full h-44 object-cover rounded-t-lg bg-white"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
|
||||
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
|
||||
alt={urls[0]}
|
||||
className="w-full h-44 object-cover rounded-t-lg bg-white"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<h5 className="leading-none font-medium text-zinc-200">
|
||||
|
@ -1,17 +1,9 @@
|
||||
import { MediaOutlet, MediaPlayer } from "@vidstack/react";
|
||||
|
||||
export function VideoPreview({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="relative mt-3 max-w-[420px] flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
|
||||
>
|
||||
{urls.map((url: string) => (
|
||||
<MediaPlayer key={url} src={urls[0]} poster="" controls>
|
||||
<MediaOutlet />
|
||||
</MediaPlayer>
|
||||
))}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -10,8 +10,9 @@ import { useContext, useState } from "react";
|
||||
|
||||
export function NoteReplyForm({ id }: { id: string }) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const account = useActiveAccount((state: any) => state.account);
|
||||
const { user } = useProfile(account.npub);
|
||||
const account = useActiveAccount((state) => state.account);
|
||||
|
||||
const { status, user } = useProfile(account.npub);
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
@ -46,35 +47,41 @@ export function NoteReplyForm({ id }: { id: string }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-zinc-800 w-full py-3 px-5">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="relative h-9 w-9 shrink-0 rounded">
|
||||
<Image
|
||||
src={user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={account.npub}
|
||||
className="h-9 w-9 rounded-md bg-white object-cover"
|
||||
/>
|
||||
{status === "loading" ? (
|
||||
<div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="relative h-9 w-9 shrink-0 rounded">
|
||||
<Image
|
||||
src={user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={account.npub}
|
||||
className="h-9 w-9 rounded-md bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-px leading-none text-sm text-zinc-400">
|
||||
Reply as
|
||||
</p>
|
||||
<p className="leading-none text-sm font-medium text-zinc-100">
|
||||
{user?.nip05 || user?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-px leading-none text-sm text-zinc-400">
|
||||
Reply as
|
||||
</p>
|
||||
<p className="leading-none text-sm font-medium text-zinc-100">
|
||||
{user?.nip05 || user?.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
preset="publish"
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
preset="publish"
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -2,12 +2,12 @@ import { getReplies } from "@libs/storage";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { EmptyIcon } from "@shared/icons";
|
||||
import { Reply } from "@shared/notes/replies/item";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = ([, id]) => getReplies(id);
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function RepliesList({ parent_id }: { parent_id: string }) {
|
||||
const { data }: any = useSWR(["note-replies", parent_id], fetcher);
|
||||
const { data } = useQuery(["replies", parent_id], async () => {
|
||||
return await getReplies(parent_id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
|
@ -4,7 +4,6 @@ import { NoteMetadata } from "@shared/notes/metadata";
|
||||
import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { User } from "@shared/user";
|
||||
import { useEvent } from "@utils/hooks/useEvent";
|
||||
import { parser } from "@utils/parser";
|
||||
import { getRepostID } from "@utils/transform";
|
||||
import { LumeEvent } from "@utils/types";
|
||||
|
||||
@ -13,14 +12,16 @@ export function Repost({
|
||||
currentBlock,
|
||||
}: { event: LumeEvent; currentBlock?: number }) {
|
||||
const repostID = getRepostID(event.tags);
|
||||
const data = useEvent(repostID);
|
||||
const { status, data, isFetching } = useEvent(repostID);
|
||||
|
||||
const kind1 = data?.kind === 1 ? parser(data) : null;
|
||||
const kind1 = data?.kind === 1 ? data.content : null;
|
||||
const kind1063 = data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden flex flex-col mt-12">
|
||||
{data ? (
|
||||
{isFetching || status === "loading" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-5 pl-[49px]">
|
||||
@ -48,8 +49,6 @@ export function Repost({
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
13
src/shared/protected.tsx
Normal file
13
src/shared/protected.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { useAccount } from "@utils/hooks/useAccount";
|
||||
import { ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export function Protected({ children }: { children: ReactNode }) {
|
||||
const { status, account } = useAccount();
|
||||
|
||||
if (status === "success" && !account) {
|
||||
return <Navigate to="/auth/welcome" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
@ -1,9 +1,12 @@
|
||||
import { initNDK } from "@libs/ndk";
|
||||
import NDK from "@nostr-dev-kit/ndk";
|
||||
import { FULL_RELAYS } from "@stores/constants";
|
||||
import { createContext } from "react";
|
||||
|
||||
export const RelayContext = createContext<NDK>(null);
|
||||
const ndk = await initNDK();
|
||||
|
||||
const ndk = new NDK({ explicitRelayUrls: FULL_RELAYS });
|
||||
await ndk.connect();
|
||||
|
||||
export function RelayProvider({ children }: { children: React.ReactNode }) {
|
||||
return <RelayContext.Provider value={ndk}>{children}</RelayContext.Provider>;
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Image } from "@shared/image";
|
||||
import { Link } from "@shared/link";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { Fragment } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@ -96,13 +96,13 @@ export function User({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<Link
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
to={`/app/user/${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-700 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
View profile
|
||||
</Link>
|
||||
<Link
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
to={`/app/chat/${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-700 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
|
@ -7,6 +7,5 @@ export const OPENGRAPH_KEY = "9EJG4SY-19Q4M5J-H8R29C9-091XPCC";
|
||||
export const FULL_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band/all",
|
||||
"wss://relay.nostrich.land",
|
||||
"wss://relay.nostrgraph.net",
|
||||
];
|
||||
|
17
src/stores/onboarding.tsx
Normal file
17
src/stores/onboarding.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export const useOnboarding = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
profile: {},
|
||||
createProfile: (data) => {
|
||||
set({ profile: data });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "onboarding",
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
},
|
||||
),
|
||||
);
|
11
src/utils/hooks/useAccount.tsx
Normal file
11
src/utils/hooks/useAccount.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { getActiveAccount } from "@libs/storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function useAccount() {
|
||||
const { status, data: account } = useQuery(["currentAccount"], async () => {
|
||||
const res = await getActiveAccount();
|
||||
return res;
|
||||
});
|
||||
|
||||
return { status, account };
|
||||
}
|
@ -1,31 +1,40 @@
|
||||
import { createNote, getNoteByID } from "@libs/storage";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { parser } from "@utils/parser";
|
||||
import { useContext } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async ([, ndk, id]) => {
|
||||
const result = await getNoteByID(id);
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
} else {
|
||||
const event = await ndk.fetchEvent(id);
|
||||
await createNote(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
);
|
||||
event["event_id"] = event.id;
|
||||
return event;
|
||||
}
|
||||
};
|
||||
|
||||
export function useEvent(id: string) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const { data } = useSWR(["note", ndk, id], fetcher);
|
||||
const { status, data, error, isFetching } = useQuery(
|
||||
["note", id],
|
||||
async () => {
|
||||
const result = await getNoteByID(id);
|
||||
if (result) {
|
||||
result["content"] = parser(result);
|
||||
return result;
|
||||
} else {
|
||||
const event = await ndk.fetchEvent(id);
|
||||
await createNote(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
);
|
||||
event["event_id"] = event.id;
|
||||
// @ts-ignore
|
||||
event["content"] = parser(event);
|
||||
return event;
|
||||
}
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
return { status, data, error, isFetching };
|
||||
}
|
||||
|
@ -1,28 +1,49 @@
|
||||
import { OPENGRAPH_KEY } from "@stores/constants";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetch } from "@tauri-apps/api/http";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const result = await fetch(url, {
|
||||
method: "GET",
|
||||
timeout: 20,
|
||||
});
|
||||
if (result.ok) {
|
||||
return result.data;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function useOpenGraph(url: string) {
|
||||
const { data, error, isLoading } = useSWR(
|
||||
`https://skrape.dev/api/opengraph/?url=${url}&key=${OPENGRAPH_KEY}`,
|
||||
fetcher,
|
||||
const { status, data, error, isFetching } = useQuery(
|
||||
["preview", url],
|
||||
async () => {
|
||||
const result = await fetch(
|
||||
`https://skrape.dev/api/opengraph/?url=${url}&key=${OPENGRAPH_KEY}`,
|
||||
{
|
||||
method: "GET",
|
||||
timeout: 10,
|
||||
},
|
||||
);
|
||||
if (result.ok) {
|
||||
if (Object.keys(result.data).length === 0) {
|
||||
const origin = new URL(url).origin;
|
||||
const result = await fetch(
|
||||
`https://skrape.dev/api/opengraph/?url=${origin}&key=${OPENGRAPH_KEY}`,
|
||||
{
|
||||
method: "GET",
|
||||
timeout: 10,
|
||||
},
|
||||
);
|
||||
if (result.ok) {
|
||||
return result.data;
|
||||
}
|
||||
} else {
|
||||
return result.data;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
error: error,
|
||||
isLoading: isLoading,
|
||||
status,
|
||||
data,
|
||||
error,
|
||||
isFetching,
|
||||
};
|
||||
}
|
||||
|
@ -1,44 +1,38 @@
|
||||
import { createPleb, getPleb } from "@libs/storage";
|
||||
import NDK from "@nostr-dev-kit/ndk";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useContext } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async ([, ndk, key]) => {
|
||||
let npub: string;
|
||||
|
||||
if (key.substring(0, 4) === "npub") {
|
||||
npub = key;
|
||||
} else {
|
||||
npub = nip19.npubEncode(key);
|
||||
}
|
||||
|
||||
const current = Math.floor(Date.now() / 1000);
|
||||
const result = await getPleb(npub);
|
||||
|
||||
if (result && result.created_at + 86400 > current) {
|
||||
return result;
|
||||
} else {
|
||||
const user = ndk.getUser({ npub });
|
||||
await user.fetchProfile();
|
||||
await createPleb(key, user.profile);
|
||||
|
||||
return user.profile;
|
||||
}
|
||||
};
|
||||
|
||||
export function useProfile(key: string) {
|
||||
export function useProfile(id: string) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const { data, error, isLoading } = useSWR(["profile", ndk, key], fetcher, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
const {
|
||||
status,
|
||||
data: user,
|
||||
error,
|
||||
isFetching,
|
||||
} = useQuery(["user", id], async () => {
|
||||
let npub: string;
|
||||
|
||||
if (id.substring(0, 4) === "npub") {
|
||||
npub = id;
|
||||
} else {
|
||||
npub = nip19.npubEncode(id);
|
||||
}
|
||||
|
||||
const current = Math.floor(Date.now() / 1000);
|
||||
const result = await getPleb(npub);
|
||||
|
||||
if (result && result.created_at + 86400 > current) {
|
||||
return result;
|
||||
} else {
|
||||
const user = ndk.getUser({ npub });
|
||||
await user.fetchProfile();
|
||||
await createPleb(id, user.profile);
|
||||
|
||||
return user.profile;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
user: data,
|
||||
isLoading,
|
||||
isError: error,
|
||||
};
|
||||
return { status, user, error, isFetching };
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Link } from "@shared/link";
|
||||
import { MentionUser } from "@shared/notes/mentions/user";
|
||||
import destr from "destr";
|
||||
import getUrls from "get-urls";
|
||||
import { parseReferences } from "nostr-tools";
|
||||
import { Link } from "react-router-dom";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
|
||||
function isJsonString(str) {
|
||||
@ -50,17 +50,33 @@ export function parser(event: any) {
|
||||
// image url
|
||||
content.images.push(url);
|
||||
// remove url from original content
|
||||
content.parsed = content.parsed.replace(url, "");
|
||||
content.parsed = reactStringReplace(content.parsed, url, () => null);
|
||||
} else if (url.match(/\.(mp4|webm|mov|ogv|avi|mp3)$/)) {
|
||||
// video
|
||||
content.videos.push(url);
|
||||
// remove url from original content
|
||||
content.parsed = content.parsed.replace(url, "");
|
||||
content.parsed = reactStringReplace(content.parsed, url, () => null);
|
||||
} else {
|
||||
// push to store
|
||||
content.links.push(url);
|
||||
// remove url from original content
|
||||
content.parsed = content.parsed.replace(url, "");
|
||||
if (content.links.length < 1) {
|
||||
// push to store
|
||||
content.links.push(url);
|
||||
// remove url from original content
|
||||
content.parsed = reactStringReplace(content.parsed, url, () => null);
|
||||
} else {
|
||||
content.parsed = reactStringReplace(
|
||||
content.parsed,
|
||||
/#(\w+)/g,
|
||||
(match, i) => (
|
||||
<Link
|
||||
key={match + i}
|
||||
to={match}
|
||||
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal"
|
||||
>
|
||||
{match}
|
||||
</Link>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -70,7 +86,11 @@ export function parser(event: any) {
|
||||
const event = item.event;
|
||||
if (event) {
|
||||
content.notes.push(event.id);
|
||||
content.parsed = content.parsed.replace(item.text, "");
|
||||
content.parsed = reactStringReplace(
|
||||
content.parsed,
|
||||
item.text,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
if (profile) {
|
||||
content.parsed = reactStringReplace(
|
||||
@ -85,7 +105,7 @@ export function parser(event: any) {
|
||||
content.parsed = reactStringReplace(content.parsed, /#(\w+)/g, (match, i) => (
|
||||
<Link
|
||||
key={match + i}
|
||||
href={`/search/${match}`}
|
||||
to={`/search/${match}`}
|
||||
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal"
|
||||
>
|
||||
#{match}
|
||||
|
@ -1,57 +1,10 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
input: `
|
||||
0px 1px 0px -1px var(--tw-shadow-color),
|
||||
0px 1px 1px -1px var(--tw-shadow-color),
|
||||
0px 1px 2px -1px var(--tw-shadow-color),
|
||||
0px 2px 4px -2px var(--tw-shadow-color),
|
||||
0px 3px 6px -3px var(--tw-shadow-color)
|
||||
`,
|
||||
highlight: `
|
||||
inset 0px 0px 0px 1px var(--tw-shadow-color),
|
||||
inset 0px 1px 0px var(--tw-shadow-color)
|
||||
`,
|
||||
popover: `0px 0px 7px rgba(0,0,0,0.52)`,
|
||||
inner: `
|
||||
0 2px 2px rgb(4 4 7 / 45%),
|
||||
0 8px 24px rgb(4 4 7 / 60%)
|
||||
`,
|
||||
button: `
|
||||
rgba(74, 4, 78, 0.5) 0px 2px 8px,
|
||||
rgb(74, 4, 78) 0px 2px 4px,
|
||||
rgb(74, 4, 78) 0px 0px 0px 1px,
|
||||
rgba(255, 255, 255, 0.2) 0px 0px 0px 1px inset
|
||||
`,
|
||||
'mini-button': `
|
||||
rgba(13, 16, 23, 0.36) 0px 2px 8px,
|
||||
rgba(13, 16, 23, 0.36) 0px 2px 4px,
|
||||
rgba(13, 16, 23, 0.36) 0px 0px 0px 1px,
|
||||
rgba(255, 255, 255, 0.1) 0px 0px 0px 1px inset
|
||||
`,
|
||||
},
|
||||
backgroundImage: {
|
||||
fade: 'linear-gradient(120deg, #000, transparent 30%, transparent 70%, #000)',
|
||||
},
|
||||
keyframes: {
|
||||
moveBg: {
|
||||
'0%': { backgroundPosition: '50px' },
|
||||
'20%': { backgroundPosition: '150px' },
|
||||
'40%': { backgroundPosition: '250px' },
|
||||
'60%': { backgroundPosition: '350px' },
|
||||
'80%': { backgroundPosition: '450px' },
|
||||
'100%': { backgroundPosition: '550px' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
moveBg: 'moveBg 3s ease-in-out infinite alternate running forwards',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}", "index.html"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user