This commit is contained in:
Ren Amamiya
2023-06-24 18:31:40 +07:00
parent 21d22320b3
commit 85b30f770c
102 changed files with 1844 additions and 2014 deletions

View File

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

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

View File

@@ -16,11 +16,12 @@
"@floating-ui/react": "^0.23.1", "@floating-ui/react": "^0.23.1",
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
"@nostr-dev-kit/ndk": "^0.5.13", "@nostr-dev-kit/ndk": "^0.5.13",
"@tanstack/react-query": "^4.29.15",
"@tanstack/react-virtual": "3.0.0-beta.54", "@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0", "@tauri-apps/api": "^1.4.0",
"@vidstack/react": "^0.4.5",
"dayjs": "^1.11.8", "dayjs": "^1.11.8",
"destr": "^1.2.2", "destr": "^1.2.2",
"framer-motion": "^10.12.17",
"get-urls": "^11.0.0", "get-urls": "^11.0.0",
"immer": "^10.0.2", "immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0", "light-bolt11-decoder": "^3.0.0",
@@ -30,12 +31,12 @@
"react-hook-form": "^7.45.0", "react-hook-form": "^7.45.0",
"react-hotkeys-hook": "^4.4.0", "react-hotkeys-hook": "^4.4.0",
"react-resizable-panels": "^0.0.48", "react-resizable-panels": "^0.0.48",
"react-router-dom": "^6.14.0",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"react-virtuoso": "^4.3.10", "react-virtuoso": "^4.3.11",
"slate": "^0.94.1", "slate": "^0.94.1",
"slate-history": "^0.93.0", "slate-history": "^0.93.0",
"slate-react": "^0.94.2", "slate-react": "^0.94.2",
"swr": "^2.1.5",
"tailwind-merge": "^1.13.2", "tailwind-merge": "^1.13.2",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql", "tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"vidstack": "^0.4.5", "vidstack": "^0.4.5",
@@ -45,7 +46,7 @@
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.4.0", "@tauri-apps/cli": "^1.4.0",
"@types/node": "^18.16.18", "@types/node": "^18.16.18",
"@types/react": "^18.2.13", "@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6", "@types/react-dom": "^18.2.6",
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
@@ -61,7 +62,6 @@
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite": "^4.3.9", "vite": "^4.3.9",
"vite-plugin-ssr": "^0.4.131",
"vite-plugin-top-level-await": "^1.3.1", "vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0" "vite-tsconfig-paths": "^4.2.0"
} }

268
pnpm-lock.yaml generated
View File

@@ -10,21 +10,24 @@ dependencies:
'@nostr-dev-kit/ndk': '@nostr-dev-kit/ndk':
specifier: ^0.5.13 specifier: ^0.5.13
version: 0.5.13(typescript@4.9.5) 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': '@tanstack/react-virtual':
specifier: 3.0.0-beta.54 specifier: 3.0.0-beta.54
version: 3.0.0-beta.54(react@18.2.0) version: 3.0.0-beta.54(react@18.2.0)
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^1.4.0 specifier: ^1.4.0
version: 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: dayjs:
specifier: ^1.11.8 specifier: ^1.11.8
version: 1.11.8 version: 1.11.8
destr: destr:
specifier: ^1.2.2 specifier: ^1.2.2
version: 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: get-urls:
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.0.0 version: 11.0.0
@@ -52,12 +55,15 @@ dependencies:
react-resizable-panels: react-resizable-panels:
specifier: ^0.0.48 specifier: ^0.0.48
version: 0.0.48(react-dom@18.2.0)(react@18.2.0) 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: react-string-replace:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
react-virtuoso: react-virtuoso:
specifier: ^4.3.10 specifier: ^4.3.11
version: 4.3.10(react-dom@18.2.0)(react@18.2.0) version: 4.3.11(react-dom@18.2.0)(react@18.2.0)
slate: slate:
specifier: ^0.94.1 specifier: ^0.94.1
version: 0.94.1 version: 0.94.1
@@ -67,9 +73,6 @@ dependencies:
slate-react: slate-react:
specifier: ^0.94.2 specifier: ^0.94.2
version: 0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1) 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: tailwind-merge:
specifier: ^1.13.2 specifier: ^1.13.2
version: 1.13.2 version: 1.13.2
@@ -94,8 +97,8 @@ devDependencies:
specifier: ^18.16.18 specifier: ^18.16.18
version: 18.16.18 version: 18.16.18
'@types/react': '@types/react':
specifier: ^18.2.13 specifier: ^18.2.14
version: 18.2.13 version: 18.2.14
'@types/react-dom': '@types/react-dom':
specifier: ^18.2.6 specifier: ^18.2.6
version: 18.2.6 version: 18.2.6
@@ -141,9 +144,6 @@ devDependencies:
vite: vite:
specifier: ^4.3.9 specifier: ^4.3.9
version: 4.3.9(@types/node@18.16.18) 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: vite-plugin-top-level-await:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1(vite@4.3.9) version: 1.3.1(vite@4.3.9)
@@ -179,23 +179,18 @@ packages:
js-tokens: 4.0.0 js-tokens: 4.0.0
dev: false dev: false
/@brillout/import@0.2.3: /@emotion/is-prop-valid@0.8.8:
resolution: {integrity: sha512-1T8WlD75eeFSMrptGy8jiLHmfHgMmSjWvLOIUvHmSVZt+6k0eQqYUoK4KbmE4T9pVLIfxvZSOm2D68VEqKRHRw==} resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
dev: true requiresBuild: 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==}
dependencies: dependencies:
'@brillout/import': 0.2.3 '@emotion/memoize': 0.7.4
dev: true 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: /@esbuild/android-arm64@0.17.19:
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
@@ -533,8 +528,8 @@ packages:
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
dev: false dev: false
/@maverick-js/signals@5.11.1: /@maverick-js/signals@5.11.2:
resolution: {integrity: sha512-yr6ZIpQxupgbDl6364t1L7bOSJqK2xiwjf8hoxJ632SL+0nBL0bLNCQaiK4GFRQEhYhBwX2yYyA+eKgZLEJ6mg==} resolution: {integrity: sha512-jKAyNE2O7H+xrigPoqdV0Iq2AeQ6cysfBf/b2jasJ4FfCUKjGyazgtp+pIspTW6skFvpPrvq40Qft+7HuR+Tlg==}
dev: false dev: false
/@noble/curves@1.0.0: /@noble/curves@1.0.0:
@@ -607,7 +602,7 @@ packages:
resolution: {integrity: sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==} resolution: {integrity: sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dependencies: dependencies:
semver: 7.5.2 semver: 7.5.3
dev: false dev: false
/@pkgjs/parseargs@0.11.0: /@pkgjs/parseargs@0.11.0:
@@ -617,9 +612,10 @@ packages:
dev: false dev: false
optional: true optional: true
/@polka/url@1.0.0-next.21: /@remix-run/router@1.7.0:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} resolution: {integrity: sha512-Eu1V3kz3mV0wUpVTiFHuaT8UD1gj/0VnoFHQYX35xlslQUpe8CuYoKFn9d4WZFHm3yDywz6ALZuGdnUPKrNeAw==}
dev: true engines: {node: '>=14'}
dev: false
/@rollup/plugin-virtual@3.0.1: /@rollup/plugin-virtual@3.0.1:
resolution: {integrity: sha512-fK8O0IL5+q+GrsMLuACVNk2x21g3yaw+sG2qn16SnUd3IlBsQyvWxLMGHmCmXRMecPjGRSZ/1LmZB4rjQm68og==} resolution: {integrity: sha512-fK8O0IL5+q+GrsMLuACVNk2x21g3yaw+sG2qn16SnUd3IlBsQyvWxLMGHmCmXRMecPjGRSZ/1LmZB4rjQm68og==}
@@ -826,6 +822,28 @@ packages:
tailwindcss: 3.3.2 tailwindcss: 3.3.2
dev: true 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): /@tanstack/react-virtual@3.0.0-beta.54(react@18.2.0):
resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==} resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
peerDependencies: peerDependencies:
@@ -1001,22 +1019,25 @@ packages:
/@types/prop-types@15.7.5: /@types/prop-types@15.7.5:
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
dev: true
/@types/react-dom@18.2.6: /@types/react-dom@18.2.6:
resolution: {integrity: sha512-2et4PDvg6PVCyS7fuTc4gPoksV58bW0RwSxWKcPRcHZf0PRUGq03TKcD/rUHe3azfV6/5/biUBJw+HhCQjaP0A==} resolution: {integrity: sha512-2et4PDvg6PVCyS7fuTc4gPoksV58bW0RwSxWKcPRcHZf0PRUGq03TKcD/rUHe3azfV6/5/biUBJw+HhCQjaP0A==}
dependencies: dependencies:
'@types/react': 18.2.13 '@types/react': 18.2.14
dev: true dev: true
/@types/react@18.2.13: /@types/react@18.2.14:
resolution: {integrity: sha512-vJ+zElvi/Zn9cVXB5slX2xL8PZodPCwPRDpittQdw43JR2AJ5k3vKdgJJyneV/cYgIbLQUwXa9JVDvUZXGba+Q==} resolution: {integrity: sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==}
dependencies: dependencies:
'@types/prop-types': 15.7.5 '@types/prop-types': 15.7.5
'@types/scheduler': 0.16.3 '@types/scheduler': 0.16.3
csstype: 3.1.2 csstype: 3.1.2
dev: true
/@types/scheduler@0.16.3: /@types/scheduler@0.16.3:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
dev: true
/@types/semver@7.5.0: /@types/semver@7.5.0:
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
@@ -1047,7 +1068,7 @@ packages:
grapheme-splitter: 1.0.4 grapheme-splitter: 1.0.4
ignore: 5.2.4 ignore: 5.2.4
natural-compare-lite: 1.4.0 natural-compare-lite: 1.4.0
semver: 7.5.2 semver: 7.5.3
tsutils: 3.21.0(typescript@4.9.5) tsutils: 3.21.0(typescript@4.9.5)
typescript: 4.9.5 typescript: 4.9.5
transitivePeerDependencies: transitivePeerDependencies:
@@ -1121,7 +1142,7 @@ packages:
debug: 4.3.4 debug: 4.3.4
globby: 11.1.0 globby: 11.1.0
is-glob: 4.0.3 is-glob: 4.0.3
semver: 7.5.2 semver: 7.5.3
tsutils: 3.21.0(typescript@4.9.5) tsutils: 3.21.0(typescript@4.9.5)
typescript: 4.9.5 typescript: 4.9.5
transitivePeerDependencies: transitivePeerDependencies:
@@ -1142,7 +1163,7 @@ packages:
'@typescript-eslint/typescript-estree': 5.60.0(typescript@4.9.5) '@typescript-eslint/typescript-estree': 5.60.0(typescript@4.9.5)
eslint: 8.43.0 eslint: 8.43.0
eslint-scope: 5.1.1 eslint-scope: 5.1.1
semver: 7.5.2 semver: 7.5.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
@@ -1156,23 +1177,6 @@ packages:
eslint-visitor-keys: 3.4.1 eslint-visitor-keys: 3.4.1
dev: false 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): /@vitejs/plugin-react-swc@3.3.2(vite@4.3.9):
resolution: {integrity: sha512-VJFWY5sfoZerQRvJrh518h3AcQt6f/yTuWn4/TRB+dqmYU0NX1qz7qM5Wfd+gOQqUzQW4gxKqKN3KpE/P3+zrA==} resolution: {integrity: sha512-VJFWY5sfoZerQRvJrh518h3AcQt6f/yTuWn4/TRB+dqmYU0NX1qz7qM5Wfd+gOQqUzQW4gxKqKN3KpE/P3+zrA==}
peerDependencies: peerDependencies:
@@ -1200,6 +1204,7 @@ packages:
resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==} resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
dev: false
/agent-base@6.0.2: /agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
@@ -1373,7 +1378,7 @@ packages:
postcss: ^8.1.0 postcss: ^8.1.0
dependencies: dependencies:
browserslist: 4.21.9 browserslist: 4.21.9
caniuse-lite: 1.0.30001506 caniuse-lite: 1.0.30001507
fraction.js: 4.2.0 fraction.js: 4.2.0
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.0.0 picocolors: 1.0.0
@@ -1417,8 +1422,8 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
dependencies: dependencies:
caniuse-lite: 1.0.30001506 caniuse-lite: 1.0.30001507
electron-to-chromium: 1.4.435 electron-to-chromium: 1.4.440
node-releases: 2.0.12 node-releases: 2.0.12
update-browserslist-db: 1.0.11(browserslist@4.21.9) update-browserslist-db: 1.0.11(browserslist@4.21.9)
dev: true dev: true
@@ -1431,18 +1436,13 @@ packages:
node-gyp-build: 4.6.0 node-gyp-build: 4.6.0
dev: false dev: false
/cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
dev: true
/cacache@17.1.3: /cacache@17.1.3:
resolution: {integrity: sha512-jAdjGxmPxZh0IipMdR7fK/4sDSrHMLUV0+GvVUsjwyGNKHsh79kW/otg+GkbXwl6Uzvy9wsvHOX4nUoWldeZMg==} resolution: {integrity: sha512-jAdjGxmPxZh0IipMdR7fK/4sDSrHMLUV0+GvVUsjwyGNKHsh79kW/otg+GkbXwl6Uzvy9wsvHOX4nUoWldeZMg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dependencies: dependencies:
'@npmcli/fs': 3.1.0 '@npmcli/fs': 3.1.0
fs-minipass: 3.0.2 fs-minipass: 3.0.2
glob: 10.2.7 glob: 10.3.0
lru-cache: 7.18.3 lru-cache: 7.18.3
minipass: 5.0.0 minipass: 5.0.0
minipass-collect: 1.0.2 minipass-collect: 1.0.2
@@ -1485,8 +1485,8 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false dev: false
/caniuse-lite@1.0.30001506: /caniuse-lite@1.0.30001507:
resolution: {integrity: sha512-6XNEcpygZMCKaufIcgpQNZNf00GEqc7VQON+9Rd0K1bMYo8xhMZRAo5zpbnbMNizi4YNgIDAFrdykWsvY3H4Hw==} resolution: {integrity: sha512-SFpUDoSLCaE5XYL2jfqe9ova/pbQHEmbheDf5r4diNwbAgR3qxM9NQtfsiSscjqoya5K7kFcHPUQ+VsUkIJR4A==}
dev: true dev: true
/chalk@2.4.2: /chalk@2.4.2:
@@ -1615,7 +1615,7 @@ packages:
resolution: {integrity: sha512-cllzD6IU/mzXBs5OdQVWL3+ne5Elpu3Wdm7h5OldMbGXk76yr9XzHlQXWJ4zfs0ZAibe26rkbs4KvMAJm7fIZA==} resolution: {integrity: sha512-cllzD6IU/mzXBs5OdQVWL3+ne5Elpu3Wdm7h5OldMbGXk76yr9XzHlQXWJ4zfs0ZAibe26rkbs4KvMAJm7fIZA==}
engines: {node: '>=14.x'} engines: {node: '>=14.x'}
dependencies: dependencies:
semver: 7.5.2 semver: 7.5.3
dev: false dev: false
/cross-env@7.0.3: /cross-env@7.0.3:
@@ -1653,6 +1653,7 @@ packages:
/csstype@3.1.2: /csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
dev: true
/d@1.0.1: /d@1.0.1:
resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==} resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==}
@@ -1783,8 +1784,8 @@ packages:
/eastasianwidth@0.2.0: /eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
/electron-to-chromium@1.4.435: /electron-to-chromium@1.4.440:
resolution: {integrity: sha512-B0CBWVFhvoQCW/XtjRzgrmqcgVWg6RXOEM/dK59+wFV93BFGR6AeNKc4OyhM+T3IhJaOOG8o/V+33Y2mwJWtzw==} resolution: {integrity: sha512-r6dCgNpRhPwiWlxbHzZQ/d9swfPaEJGi8ekqRBwQYaR3WmA5VkqQfBWSDDjuJU1ntO+W9tHx8OHV/96Q8e0dVw==}
dev: true dev: true
/emoji-regex@8.0.0: /emoji-regex@8.0.0:
@@ -1853,10 +1854,6 @@ packages:
which-typed-array: 1.1.9 which-typed-array: 1.1.9
dev: false 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: /es-set-tostringtag@2.0.1:
resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2127,7 +2124,7 @@ packages:
dependencies: dependencies:
create-esm-loader: 0.2.3 create-esm-loader: 0.2.3
npm-run-all: 4.1.5 npm-run-all: 4.1.5
semver: 7.5.2 semver: 7.5.3
typescript: 5.1.3 typescript: 5.1.3
dev: false dev: false
@@ -2299,6 +2296,24 @@ packages:
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true 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: /fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2400,14 +2415,14 @@ packages:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
/glob@10.2.7: /glob@10.3.0:
resolution: {integrity: sha512-jTKehsravOJo8IJxUGfZILnkvVJM/MOfHRs8QcXolVef2zNI9Tqyy5+SeuOAZd3upViEZQLyFpQhYiHLrMUNmA==} resolution: {integrity: sha512-AQ1/SB9HH0yCx1jXAT4vmCbTOPe5RQ+kCurjbel5xSCGhebumUv+GJZfa1rEqor3XIViqwSEmlkZCQD43RWrBg==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
hasBin: true hasBin: true
dependencies: dependencies:
foreground-child: 3.1.1 foreground-child: 3.1.1
jackspeak: 2.2.1 jackspeak: 2.2.1
minimatch: 9.0.1 minimatch: 9.0.2
minipass: 5.0.0 minipass: 5.0.0
path-scurry: 1.9.2 path-scurry: 1.9.2
dev: false dev: false
@@ -3078,7 +3093,7 @@ packages:
resolution: {integrity: sha512-p8L5V62CV6TmHAngmRAopp231oJKeH77mJja5SsKOfvzrPRoThT/Jo9U0jMRB5iMykqkvyg2J5V5Agn6FPXDWQ==} resolution: {integrity: sha512-p8L5V62CV6TmHAngmRAopp231oJKeH77mJja5SsKOfvzrPRoThT/Jo9U0jMRB5iMykqkvyg2J5V5Agn6FPXDWQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
dependencies: dependencies:
'@maverick-js/signals': 5.11.1 '@maverick-js/signals': 5.11.2
type-fest: 3.12.0 type-fest: 3.12.0
dev: false dev: false
@@ -3150,8 +3165,8 @@ packages:
dependencies: dependencies:
brace-expansion: 1.1.11 brace-expansion: 1.1.11
/minimatch@9.0.1: /minimatch@9.0.2:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} resolution: {integrity: sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
dependencies: dependencies:
brace-expansion: 2.0.1 brace-expansion: 2.0.1
@@ -3235,11 +3250,6 @@ packages:
hasBin: true hasBin: true
dev: false dev: false
/mrmime@1.0.1:
resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==}
engines: {node: '>=10'}
dev: true
/ms@2.0.0: /ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: false dev: false
@@ -3322,7 +3332,7 @@ packages:
nopt: 6.0.0 nopt: 6.0.0
npmlog: 6.0.2 npmlog: 6.0.2
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.5.2 semver: 7.5.3
tar: 6.1.15 tar: 6.1.15
which: 2.0.2 which: 2.0.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -3356,7 +3366,7 @@ packages:
dependencies: dependencies:
hosted-git-info: 4.1.0 hosted-git-info: 4.1.0
is-core-module: 2.12.1 is-core-module: 2.12.1
semver: 7.5.2 semver: 7.5.3
validate-npm-package-license: 3.0.4 validate-npm-package-license: 3.0.4
dev: false dev: false
@@ -3817,13 +3827,36 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false 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: /react-string-replace@1.1.1:
resolution: {integrity: sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==} resolution: {integrity: sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
dev: false dev: false
/react-virtuoso@4.3.10(react-dom@18.2.0)(react@18.2.0): /react-virtuoso@4.3.11(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-/LDICLCmPRDMOheCKmuHj/U7CGjLT/WtMWZGfOothhhubQeWGbR6mtGyd+uD80Yw/n3ICZtYwERQZnTM8eC0ag==} resolution: {integrity: sha512-0YrCvQ5GsIKRcN34GxrzhSJGuMNI+hGxWci5cTVuPQ8QWTEsrKfCyqm7YNBMmV3pu7onG1YVUBo86CyCXdejXg==}
engines: {node: '>=10'} engines: {node: '>=10'}
peerDependencies: peerDependencies:
react: '>=16 || >=17 || >= 18' react: '>=16 || >=17 || >= 18'
@@ -4018,8 +4051,8 @@ packages:
hasBin: true hasBin: true
dev: false dev: false
/semver@7.5.2: /semver@7.5.3:
resolution: {integrity: sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==} resolution: {integrity: sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
dependencies: dependencies:
@@ -4072,15 +4105,6 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
dev: false 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: /slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4343,15 +4367,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} 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: /tabbable@6.1.2:
resolution: {integrity: sha512-qCN98uP7i9z0fIS4amQ5zbGBOq+OSigYeGvPy7NDk8Y9yncqDZ9pRPgfsc2PJIVM9RrJj7GIfuRgmjoUU9zTHQ==} resolution: {integrity: sha512-qCN98uP7i9z0fIS4amQ5zbGBOq+OSigYeGvPy7NDk8Y9yncqDZ9pRPgfsc2PJIVM9RrJj7GIfuRgmjoUU9zTHQ==}
dev: false dev: false
@@ -4444,11 +4459,6 @@ packages:
dependencies: dependencies:
is-number: 7.0.0 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: /trim-newlines@3.0.1:
resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4684,30 +4694,6 @@ packages:
type-fest: 3.12.0 type-fest: 3.12.0
dev: false 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): /vite-plugin-top-level-await@1.3.1(vite@4.3.9):
resolution: {integrity: sha512-55M1h4NAwkrpxPNOJIBzKZFihqLUzIgnElLSmPNPMR2Fn9+JHKaNg3sVX1Fq+VgvuBksQYxiD3OnwQAUu7kaPQ==} resolution: {integrity: sha512-55M1h4NAwkrpxPNOJIBzKZFihqLUzIgnElLSmPNPMR2Fn9+JHKaNg3sVX1Fq+VgvuBksQYxiD3OnwQAUu7kaPQ==}
peerDependencies: peerDependencies:

View File

@@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; };

View File

@@ -3,7 +3,7 @@
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build", "beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:3000", "devPath": "http://localhost:3000",
"distDir": "../dist/client", "distDir": "../dist",
"withGlobalTauri": true "withGlobalTauri": true
}, },
"package": { "package": {

91
src/app.tsx Normal file
View 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>
);
}

View File

@@ -1 +0,0 @@
export { LayoutOnboarding as Layout } from "./layout";

View File

@@ -4,39 +4,39 @@ import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { shortenKey } from "@utils/shortenKey";
export function User({ pubkey }: { pubkey: string }) { export function User({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { status, 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>
);
}
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink rounded-md"> {status === "loading" ? (
<Image <>
src={user.image} <div className="relative h-11 w-11 shrink-0 rounded-md bg-zinc-800 animate-pulse" />
fallback={DEFAULT_AVATAR} <div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
alt={pubkey} <span className="w-1/2 h-4 rounded bg-zinc-800 animate-pulse" />
className="h-11 w-11 rounded-md object-cover" <span className="w-1/3 h-3 rounded bg-zinc-800 animate-pulse" />
decoding="async" </div>
/> </>
</div> ) : (
<div className="flex w-full flex-1 flex-col items-start text-start"> <>
<span className="truncate font-medium leading-tight text-zinc-100"> <div className="relative h-11 w-11 shrink rounded-md">
{user.displayName || user.name} <Image
</span> src={user.image}
<span className="text-base leading-tight text-zinc-400"> fallback={DEFAULT_AVATAR}
{user.nip05?.toLowerCase() || shortenKey(pubkey)} alt={pubkey}
</span> className="h-11 w-11 rounded-md object-cover"
</div> 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> </div>
); );
} }

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

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

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

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

View File

@@ -1,11 +1,13 @@
import { User } from "@app/auth/components/user"; import { User } from "@app/auth/components/user";
import { updateAccount } from "@libs/storage";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CheckCircleIcon, LoaderIcon } from "@shared/icons"; import { CheckCircleIcon, LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider"; 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 { arrayToNIP02 } from "@utils/transform";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { navigate } from "vite-plugin-ssr/client/router"; import { useNavigate } from "react-router-dom";
const initialList = [ const initialList = [
{ {
@@ -106,15 +108,15 @@ const initialList = [
}, },
]; ];
export function Page() { export function CreateStep4Screen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]); const [follows, setFollows] = useState([]);
const [account, updateFollows] = useActiveAccount((state: any) => [
state.account, const { account } = useAccount();
state.updateFollows,
]);
// toggle follow state // toggle follow state
const toggleFollow = (pubkey: string) => { const toggleFollow = (pubkey: string) => {
@@ -124,6 +126,16 @@ export function Page() {
setFollows(arr); 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 // save follows to database then broadcast
const submit = async () => { const submit = async () => {
try { try {
@@ -142,75 +154,64 @@ export function Page() {
// publish event // publish event
event.publish(); event.publish();
// update account follows // update
updateFollows(follows); update.mutate(follows);
// redirect to onboarding
setTimeout(
() =>
navigate("/app/onboarding", {
overwriteLastHistoryEntry: true,
}),
2000,
);
} catch { } catch {
console.log("error"); console.log("error");
} }
}; };
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="mx-auto w-full max-w-md">
<div className="mx-auto w-full max-w-md"> <div className="mb-8 text-center">
<div className="mb-8 text-center"> <h1 className="text-xl font-semibold text-zinc-100">
<h1 className="text-xl font-semibold text-zinc-100"> Personalized your newsfeed
Personalized your newsfeed </h1>
</h1> </div>
</div> <div className="flex flex-col gap-4">
<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="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">
<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
Follow at least <span className="text-fuchsia-500 font-semibold">
<span className="text-fuchsia-500 font-semibold"> {follows.length}/10
{follows.length}/10 </span>{" "}
</span>{" "} plebs
plebs </div>
</div> <div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2"> {initialList.map((item: { pubkey: string }, index: number) => (
{initialList.map((item: { pubkey: string }, index: number) => ( <button
<button key={`item-${index}`}
key={`item-${index}`} type="button"
type="button" onClick={() => toggleFollow(item.pubkey)}
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"
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} />
<User pubkey={item.pubkey} /> {follows.includes(item.pubkey) && (
{follows.includes(item.pubkey) && ( <div>
<div> <CheckCircleIcon
<CheckCircleIcon width={16}
width={16} height={16}
height={16} className="text-green-400"
className="text-green-400" />
/> </div>
</div> )}
)} </button>
</button> ))}
))}
</div>
</div> </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>
{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>
</div> </div>
); );

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

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

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

View File

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

View File

@@ -1,26 +1,17 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle"; import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { Link } from "@shared/link";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { User } from "@shared/user"; import { User } from "@shared/user";
import { useActiveAccount } from "@stores/accounts";
import { dateToUnix } from "@utils/date"; import { dateToUnix } from "@utils/date";
import { useContext, useEffect } from "react"; import { useAccount } from "@utils/hooks/useAccount";
import { navigate } from "vite-plugin-ssr/client/router"; import { useContext } from "react";
import { Link, useNavigate } from "react-router-dom";
export function Page() { export function OnboardingScreen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const navigate = useNavigate();
const [account, fetchAccount] = useActiveAccount((state: any) => [ const { status, account } = useAccount();
state.account,
state.fetch,
]);
useEffect(() => {
if (account === null) {
fetchAccount();
}
}, [fetchAccount]);
const publish = async () => { const publish = async () => {
try { try {
@@ -39,10 +30,7 @@ export function Page() {
event.publish(); event.publish();
// redirect to home // redirect to home
setTimeout( navigate("/", { replace: true });
() => navigate("/", { overwriteLastHistoryEntry: true }),
2000,
);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@@ -64,7 +52,7 @@ export function Page() {
</div> </div>
<div className="w-full border-t border-zinc-800/50 bg-zinc-900 rounded-xl"> <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"> <div className="h-min w-full px-5 py-3">
{account && ( {status === "success" && (
<User <User
pubkey={account.pubkey} pubkey={account.pubkey}
time={Math.floor(Date.now() / 1000)} time={Math.floor(Date.now() / 1000)}
@@ -97,7 +85,7 @@ export function Page() {
<ArrowRightCircleIcon className="w-5 h-5" /> <ArrowRightCircleIcon className="w-5 h-5" />
</button> </button>
<Link <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" 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 Skip for now

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle"; import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { Link } from "react-router-dom";
export function Page() { export function WelcomeScreen() {
return ( return (
<div className="w-full h-full grid grid-cols-12 gap-4 px-4 py-4"> <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"> <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> </h3>
</div> </div>
<div className="mt-auto w-full flex flex-col gap-2 px-4 py-4"> <div className="mt-auto w-full flex flex-col gap-2 px-4 py-4">
<a <Link
href="/app/auth/import" 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" 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 className="w-5" />
<span>Login with private key</span> <span>Login with private key</span>
<ArrowRightCircleIcon className="w-5 h-5" /> <ArrowRightCircleIcon className="w-5 h-5" />
</a> </Link>
<a <Link
href="/app/auth/create" 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" 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 Create new key
</a> </Link>
</div> </div>
</div> </div>
<div <div

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -5,20 +5,24 @@ import { AvatarUploader } from "@shared/avatarUploader";
import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons"; import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons";
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date"; import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useEffect, useState } from "react"; import { Fragment, useContext, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { navigate } from "vite-plugin-ssr/client/router"; import { useNavigate } from "react-router-dom";
export function ChannelCreateModal() { export function ChannelCreateModal() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account); const queryClient = useQueryClient();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const { account } = useAccount();
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
@@ -36,6 +40,21 @@ export function ChannelCreateModal() {
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm(); } = 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) => { const onSubmit = (data: any) => {
setLoading(true); setLoading(true);
@@ -55,7 +74,12 @@ export function ChannelCreateModal() {
event.publish(); event.publish();
// insert to database // 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 form
reset(); reset();
@@ -64,7 +88,7 @@ export function ChannelCreateModal() {
// close modal // close modal
setIsOpen(false); setIsOpen(false);
// redirect to channel page // redirect to channel page
navigate(`/app/channel?id=${event.id}`); navigate(`/app/channel/${event.id}`);
}, 1000); }, 1000);
} catch (e) { } catch (e) {
console.log("error: ", e); console.log("error: ", e);

View File

@@ -1,29 +1,20 @@
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile"; import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
import { Link } from "@shared/link"; import { NavLink } from "react-router-dom";
import { usePageContext } from "@utils/hooks/usePageContext";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function ChannelsListItem({ data }: { data: any }) { export function ChannelsListItem({ data }: { data: any }) {
const channel: any = useChannelProfile(data.event_id); const channel = useChannelProfile(data.event_id);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pageID = searchParams.id;
return ( return (
<Link <NavLink
href={`/app/channel?id=${data.event_id}`} to={`/app/channel/${data.event_id}`}
className={twMerge( className={({ isActive }) =>
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5", twMerge(
pageID === data.event_id ? "bg-zinc-900 text-zinc-100" : "", "inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
)} isActive ? "bg-zinc-900/50 text-zinc-100" : "",
)
}
> >
<div <div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
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" : "",
)}
>
<span className="text-xs text-zinc-100">#</span> <span className="text-xs text-zinc-100">#</span>
</div> </div>
<div className="w-full inline-flex items-center justify-between"> <div className="w-full inline-flex items-center justify-between">
@@ -36,6 +27,6 @@ export function ChannelsListItem({ data }: { data: any }) {
)} )}
</div> </div>
</div> </div>
</Link> </NavLink>
); );
} }

View File

@@ -1,27 +1,36 @@
import { ChannelCreateModal } from "@app/channel/components/createModal"; import { ChannelCreateModal } from "@app/channel/components/createModal";
import { ChannelsListItem } from "@app/channel/components/item"; import { ChannelsListItem } from "@app/channel/components/item";
import { useChannels } from "@stores/channels"; import { getChannels } from "@libs/storage";
import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query";
export function ChannelsList() { export function ChannelsList() {
const channels = useChannels((state: any) => state.channels); const {
const fetchChannels = useChannels((state: any) => state.fetch); status,
data: channels,
useEffect(() => { isFetching,
fetchChannels(); } = useQuery(
}, [fetchChannels]); ["channels"],
async () => {
return await getChannels();
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
);
return ( return (
<div className="flex flex-col"> <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="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" /> <div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </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-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" /> <div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
</> </>
) : ( ) : (
@@ -29,6 +38,12 @@ export function ChannelsList() {
<ChannelsListItem key={item.event_id} data={item} /> <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 /> <ChannelCreateModal />
</div> </div>
); );

View File

@@ -1,11 +1,14 @@
import { Member } from "@app/channel/components/member"; import { Member } from "@app/channel/components/member";
import { getChannelUsers } from "@libs/storage"; import { getChannelUsers } from "@libs/storage";
import useSWR from "swr"; import { useQuery } from "@tanstack/react-query";
const fetcher = ([, id]) => getChannelUsers(id);
export function ChannelMembers({ id }: { id: string }) { 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 ( return (
<div className="mt-3"> <div className="mt-3">
@@ -13,8 +16,7 @@ export function ChannelMembers({ id }: { id: string }) {
Members Members
</h5> </h5>
<div className="mt-3 w-full flex flex-wrap gap-1.5"> <div className="mt-3 w-full flex flex-wrap gap-1.5">
{isLoading && <p>Loading...</p>} {status === "loading" || isFetching ? (
{!data ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
data.map((member: { pubkey: string }) => ( data.map((member: { pubkey: string }) => (

View File

@@ -3,14 +3,13 @@ import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, EnterIcon } from "@shared/icons"; import { CancelIcon, EnterIcon } from "@shared/icons";
import { MediaUploader } from "@shared/mediaUploader"; import { MediaUploader } from "@shared/mediaUploader";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { useChannelMessages } from "@stores/channels"; import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date"; import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
export function ChannelMessageForm({ channelID }: { channelID: string }) { export function ChannelMessageForm({ channelID }: { channelID: string }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [replyTo, closeReply] = useChannelMessages((state: any) => [ const [replyTo, closeReply] = useChannelMessages((state: any) => [
@@ -18,6 +17,8 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
state.closeReply, state.closeReply,
]); ]);
const { account } = useAccount();
const submit = () => { const submit = () => {
let tags: string[][]; let tags: string[][];

View File

@@ -1,23 +1,15 @@
import { getChannel, updateChannelMetadata } from "@libs/storage"; import { getChannel, updateChannelMetadata } from "@libs/storage";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useContext } from "react"; import { useQuery } from "@tanstack/react-query";
import useSWR from "swr"; import { useContext, useEffect } from "react";
import useSWRSubscription from "swr/subscription";
const fetcher = async ([, id]) => {
const result = await getChannel(id);
if (result) {
return result;
} else {
return null;
}
};
export function useChannelProfile(id: string) { export function useChannelProfile(id: string) {
const ndk = useContext(RelayContext); 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 // subscribe to channel
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ {
@@ -32,14 +24,12 @@ export function useChannelProfile(id: string) {
sub.addListener("event", (event: { content: string }) => { sub.addListener("event", (event: { content: string }) => {
// update in local database // update in local database
updateChannelMetadata(id, event.content); updateChannelMetadata(id, event.content);
// revaildate
mutate();
}); });
return () => { return () => {
sub.stop(); sub.stop();
}; };
}); }, []);
return data; return data;
} }

View File

@@ -1,15 +1,20 @@
import { ChannelMessageItem } from "../components/messages/item"; import { ChannelMessageItem } from "./components/messages/item";
import { ChannelMembers } from "@app/channel/components/members"; import { ChannelMembers } from "@app/channel/components/members";
import { ChannelMessageForm } from "@app/channel/components/messages/form"; import { ChannelMessageForm } from "@app/channel/components/messages/form";
import { ChannelMetadata } from "@app/channel/components/metadata"; import { ChannelMetadata } from "@app/channel/components/metadata";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useChannelMessages } from "@stores/channels"; import { useChannelMessages } from "@stores/channels";
import { dateToUnix, getHourAgo } from "@utils/date"; import { dateToUnix, getHourAgo } from "@utils/date";
import { usePageContext } from "@utils/hooks/usePageContext";
import { LumeEvent } from "@utils/types"; 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 { Virtuoso } from "react-virtuoso";
import useSWRSubscription from "swr/subscription";
const now = new Date(); const now = new Date();
@@ -42,13 +47,11 @@ const Empty = (
</div> </div>
); );
export function Page() { export function ChannelScreen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const pageContext = usePageContext();
const virtuosoRef = useRef(null); const virtuosoRef = useRef(null);
const searchParams: any = pageContext.urlParsed.search; const { id } = useParams();
const channelID = searchParams.id;
const [messages, fetchMessages, addMessage, clearMessages] = const [messages, fetchMessages, addMessage, clearMessages] =
useChannelMessages((state: any) => [ useChannelMessages((state: any) => [
@@ -58,36 +61,30 @@ export function Page() {
state.clear, state.clear,
]); ]);
useSWRSubscription( useLayoutEffect(() => {
channelID ? ["channelMessagesSubscribe", channelID] : null, fetchMessages(id);
() => { }, [fetchMessages]);
// 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();
};
},
);
useEffect(() => { 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 () => { return () => {
clearMessages(); clearMessages();
sub.stop();
}; };
}, [fetchMessages]); }, []);
const itemContent: any = useCallback( const itemContent: any = useCallback(
(index: string | number) => { (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"> <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> </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" 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"> <div className="p-3 flex flex-col gap-3">
<ChannelMetadata id={channelID} /> <ChannelMetadata id={id} />
<ChannelMembers id={channelID} /> <ChannelMembers id={id} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -1,22 +1,16 @@
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import { Link } from "@shared/link";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile"; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { shortenKey } from "@utils/shortenKey";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function ChatsListItem({ data }: { data: any }) { export function ChatsListItem({ data }: { data: any }) {
const pageContext = usePageContext(); const { status, user, isFetching } = useProfile(data.sender_pubkey);
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const { user, isError, isLoading } = useProfile(data.sender_pubkey);
return ( return (
<> <>
{isError && <div>error</div>} {status === "loading" && isFetching ? (
{isLoading && !user ? (
<div className="inline-flex h-9 items-center gap-2.5 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="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div> <div>
@@ -24,14 +18,14 @@ export function ChatsListItem({ data }: { data: any }) {
</div> </div>
</div> </div>
) : ( ) : (
<Link <NavLink
href={`/app/chat?pubkey=${data.sender_pubkey}`} to={`/app/chat/${data.sender_pubkey}`}
className={twMerge( className={({ isActive }) =>
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5", twMerge(
pagePubkey === data.sender_pubkey "inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
? "bg-zinc-900 text-zinc-100" 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"> <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 <Image
@@ -57,7 +51,7 @@ export function ChatsListItem({ data }: { data: any }) {
)} )}
</div> </div>
</div> </div>
</Link> </NavLink>
)} )}
</> </>
); );

View File

@@ -1,44 +1,47 @@
import { ChatsListItem } from "@app/chat/components/item"; import { ChatsListItem } from "@app/chat/components/item";
import { NewMessageModal } from "@app/chat/components/modal"; import { NewMessageModal } from "@app/chat/components/modal";
import { ChatsListSelfItem } from "@app/chat/components/self"; import { ChatsListSelfItem } from "@app/chat/components/self";
import { useActiveAccount } from "@stores/accounts"; import { getChatsByPubkey } from "@libs/storage";
import { useChats } from "@stores/chats"; import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react"; import { useAccount } from "@utils/hooks/useAccount";
export function ChatsList() { export function ChatsList() {
const account = useActiveAccount((state: any) => state.account); const { account } = useAccount();
const chats = useChats((state: any) => state.chats);
const fetchChats = useChats((state: any) => state.fetch);
useEffect(() => { const {
if (!account) return; status,
fetchChats(account.pubkey); data: chats,
}, [fetchChats]); isFetching,
} = useQuery(
if (!account) ["chats"],
return ( async () => {
<div className="flex flex-col"> return await getChatsByPubkey(account.pubkey);
<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" /> enabled: account ? true : false,
</div> refetchOnWindowFocus: false,
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5"> refetchOnMount: false,
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> refetchOnReconnect: false,
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" /> },
</div> );
</div>
);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<ChatsListSelfItem data={account} /> {account ? (
{!chats ? ( <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="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 className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div> </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="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 className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div> </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 /> <NewMessageModal />
</div> </div>
); );

View File

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

View File

@@ -2,16 +2,19 @@ import { Dialog, Transition } from "@headlessui/react";
import { getPlebs } from "@libs/storage"; import { getPlebs } from "@libs/storage";
import { CancelIcon, PlusIcon } from "@shared/icons"; import { CancelIcon, PlusIcon } from "@shared/icons";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import useSWR from "swr"; import { useNavigate } from "react-router-dom";
import { navigate } from "vite-plugin-ssr/client/router";
const fetcher = () => getPlebs();
export function NewMessageModal() { export function NewMessageModal() {
const navigate = useNavigate();
const { status, data, isFetching }: any = useQuery(["plebs"], async () => {
return await getPlebs();
});
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data, isLoading }: any = useSWR("plebs", fetcher);
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
@@ -23,7 +26,7 @@ export function NewMessageModal() {
const openChat = (npub: string) => { const openChat = (npub: string) => {
const pubkey = nip19.decode(npub).data; const pubkey = nip19.decode(npub).data;
navigate(`/app/chat?pubkey=${pubkey}`); navigate(`/app/chat/${pubkey}`);
}; };
return ( return (
@@ -92,8 +95,7 @@ export function NewMessageModal() {
</div> </div>
</div> </div>
<div className="h-[500px] flex flex-col pb-5 overflow-y-auto"> <div className="h-[500px] flex flex-col pb-5 overflow-y-auto">
{isLoading && <p>Loading...</p>} {status === "loading" || isFetching ? (
{!data ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
data.map((pleb) => ( data.map((pleb) => (

View File

@@ -1,21 +1,16 @@
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import { Link } from "@shared/link";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile"; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { shortenKey } from "@utils/shortenKey";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function ChatsListSelfItem({ data }: { data: any }) { export function ChatsListSelfItem({ data }: { data: any }) {
const pageContext = usePageContext(); const { status, user, isFetching } = useProfile(data.pubkey);
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const { user, isLoading } = useProfile(data.pubkey);
return ( return (
<> <>
{isLoading && !user ? ( {status === "loading" && isFetching ? (
<div className="inline-flex h-9 items-center gap-2.5 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="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div> <div>
@@ -23,12 +18,14 @@ export function ChatsListSelfItem({ data }: { data: any }) {
</div> </div>
</div> </div>
) : ( ) : (
<Link <NavLink
href={`/app/chat?pubkey=${data.pubkey}`} to={`/app/chat/${data.pubkey}`}
className={twMerge( className={({ isActive }) =>
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5", twMerge(
pagePubkey === data.pubkey ? "bg-zinc-900 text-zinc-100" : "", "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"> <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 <Image
@@ -44,7 +41,7 @@ export function ChatsListSelfItem({ data }: { data: any }) {
</h5> </h5>
<span className="text-zinc-500">(you)</span> <span className="text-zinc-500">(you)</span>
</div> </div>
</Link> </NavLink>
)} )}
</> </>
); );

View File

@@ -3,14 +3,15 @@ import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { shortenKey } from "@utils/shortenKey";
import { nip19 } from "nostr-tools"; 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 }) { export function ChatSidebar({ pubkey }: { pubkey: string }) {
const navigate = useNavigate();
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
const viewProfile = () => { const viewProfile = () => {
const pubkey = nip19.decode(user.npub).data; const pubkey = nip19.decode(user.npub).data;
navigate(`/app/user?pubkey=${pubkey}`); navigate(`/app/user/${pubkey}`);
}; };
return ( return (

110
src/app/chat/index.tsx Normal file
View 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>
);

View File

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

View File

@@ -1 +0,0 @@
export const filesystemRoutingRoot = "/";

View File

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

View File

@@ -1 +0,0 @@
export { LayoutOnboarding as Layout } from "./layout";

View File

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

View File

@@ -5,29 +5,25 @@ import {
createChat, createChat,
createNote, createNote,
getChannels, getChannels,
getLastLogin,
} from "@libs/storage"; } from "@libs/storage";
import { NDKFilter } from "@nostr-dev-kit/ndk"; import { NDKFilter } from "@nostr-dev-kit/ndk";
import { LumeIcon } from "@shared/icons"; import { LumeIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { dateToUnix, getHourAgo } from "@utils/date"; import { dateToUnix, getHourAgo } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useEffect, useRef } from "react"; 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") { export function Root() {
totalNotes = await countTotalNotes();
}
export function Page() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const now = useRef(new Date()); const now = useRef(new Date());
const navigate = useNavigate();
const [account, lastLogin] = useActiveAccount((state: any) => [ const { status, account } = useAccount();
state.account,
state.lastLogin,
]);
async function fetchNotes() { async function fetchNotes() {
try { try {
@@ -150,12 +146,15 @@ export function Page() {
const chats = await fetchChats(); const chats = await fetchChats();
const channels = await fetchChannelMessages(); const channels = await fetchChannelMessages();
if (chats && channels) { if (chats && channels) {
navigate("/app/space", { overwriteLastHistoryEntry: true }); navigate("/app/space", { replace: true });
} }
} }
} }
prefetch();
}, []); if (status === "success" && account) {
prefetch();
}
}, [status]);
return ( return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100"> <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -1,19 +1,15 @@
import { getNotesByAuthor } from "@libs/storage"; import { getNotesByAuthor } from "@libs/storage";
import { CancelIcon } from "@shared/icons";
import { Note } from "@shared/notes/note"; import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton"; import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar"; import { TitleBar } from "@shared/titleBar";
import { useActiveAccount } from "@stores/accounts"; import { useActiveAccount } from "@stores/accounts";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useRef } from "react";
import useSWRInfinite from "swr/infinite";
const ITEM_PER_PAGE = 10; const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000); 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 }) { export function FeedBlock({ params }: { params: any }) {
const removeBlock = useActiveAccount((state: any) => state.removeBlock); const removeBlock = useActiveAccount((state: any) => state.removeBlock);
@@ -21,26 +17,36 @@ export function FeedBlock({ params }: { params: any }) {
removeBlock(params.id, true); removeBlock(params.id, true);
}; };
const getKey = (pageIndex, previousPageData) => { const {
if (previousPageData && !previousPageData.data) return null; status,
if (pageIndex === 0) return [params.content, 0]; data,
return [params.content, previousPageData.nextCursor]; fetchNextPage,
}; hasNextPage,
isFetching,
const { data, isLoading, size, setSize } = useSWRInfinite(getKey, fetcher); isFetchingNextPage,
}: any = useInfiniteQuery({
const notes = useMemo( queryKey: ["newsfeed", params.content],
() => (data ? data.flatMap((d) => d.data) : []), queryFn: async ({ pageParam = 0 }) => {
[data], 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 parentRef = useRef();
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: notes.length, count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 400, estimateSize: () => 500,
overscan: 2, overscan: 2,
}); });
const itemsVirtualizer = rowVirtualizer.getVirtualItems(); const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => { useEffect(() => {
@@ -50,10 +56,25 @@ export function FeedBlock({ params }: { params: any }) {
return; return;
} }
if (lastItem.index >= notes.length - 1) { if (
setSize(size + 1); 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 ( return (
<div className="shrink-0 w-[400px] border-r border-zinc-900"> <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" 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" }} style={{ contain: "strict" }}
> >
{!data || isLoading ? ( {status === "loading" || isFetching ? (
<div className="px-3 py-1.5"> <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"> <div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />
@@ -85,20 +106,9 @@ export function FeedBlock({ params }: { params: any }) {
}px)`, }px)`,
}} }}
> >
{rowVirtualizer.getVirtualItems().map((virtualRow) => { {rowVirtualizer
const note = notes[virtualRow.index]; .getVirtualItems()
if (note) { .map((virtualRow) => renderItem(virtualRow.index))}
return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<Note event={note} block={params.id} />
</div>
);
}
})}
</div> </div>
</div> </div>
)} )}

View File

@@ -1,68 +1,44 @@
import { createNote, getNotes } from "@libs/storage"; 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 { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton"; import { NoteSkeleton } from "@shared/notes/skeleton";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { TitleBar } from "@shared/titleBar"; import { TitleBar } from "@shared/titleBar";
import { useActiveAccount } from "@stores/accounts"; import { useActiveAccount } from "@stores/accounts";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { dateToUnix } from "@utils/date"; import { dateToUnix } from "@utils/date";
import { useContext, useEffect, useMemo, useRef } from "react"; import { useAccount } from "@utils/hooks/useAccount";
import useSWRInfinite from "swr/infinite"; import { useContext, useEffect, useRef } from "react";
import useSWRSubscription from "swr/subscription";
const ITEM_PER_PAGE = 10; const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000); const TIME = Math.floor(Date.now() / 1000);
const fetcher = async ([, offset]) => getNotes(TIME, ITEM_PER_PAGE, offset);
export function FollowingBlock({ block }: { block: number }) { export function FollowingBlock({ block }: { block: number }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const getKey = (pageIndex, previousPageData) => { const { account } = useAccount();
if (previousPageData && !previousPageData.data) return null;
if (pageIndex === 0) return ["following", 0];
return ["following", previousPageData.nextCursor];
};
// fetch initial notes const {
const { data, isLoading, size, setSize } = useSWRInfinite(getKey, fetcher); status,
// fetch live notes data,
useSWRSubscription(account ? "eventCollector" : null, () => { fetchNextPage,
const follows = JSON.parse(account.follows); hasNextPage,
const sub = ndk.subscribe({ isFetching,
kinds: [1, 6], isFetchingNextPage,
authors: follows, }: any = useInfiniteQuery({
since: dateToUnix(), queryKey: ["newsfeed-circle"],
}); queryFn: async ({ pageParam = 0 }) => {
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
sub.addListener("event", (event: NDKEvent) => { },
// save note getNextPageParam: (lastPage) => lastPage.nextCursor,
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
});
return () => {
sub.stop();
};
}); });
const notes = useMemo( const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
() => (data ? data.flatMap((d) => d.data) : []),
[data],
);
const parentRef = useRef(); const parentRef = useRef();
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: notes.length, count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 500, estimateSize: () => 500,
overscan: 2, overscan: 2,
@@ -77,10 +53,43 @@ export function FollowingBlock({ block }: { block: number }) {
return; return;
} }
if (lastItem.index >= notes.length - 1) { if (
setSize(size + 1); 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 renderItem = (index: string | number) => {
const note = notes[index]; 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" 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" }} style={{ contain: "strict" }}
> >
{!data || isLoading ? ( {status === "loading" ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20"> <div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />
@@ -129,6 +138,13 @@ export function FollowingBlock({ block }: { block: number }) {
</div> </div>
</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>
</div> </div>
); );

View File

@@ -1,5 +1,4 @@
import { getNoteByID } from "@libs/storage"; import { getNoteByID } from "@libs/storage";
import { ArrowLeftIcon } from "@shared/icons";
import { Kind1 } from "@shared/notes/contents/kind1"; import { Kind1 } from "@shared/notes/contents/kind1";
import { Kind1063 } from "@shared/notes/contents/kind1063"; import { Kind1063 } from "@shared/notes/contents/kind1063";
import { NoteMetadata } from "@shared/notes/metadata"; import { NoteMetadata } from "@shared/notes/metadata";
@@ -9,15 +8,19 @@ import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar"; import { TitleBar } from "@shared/titleBar";
import { User } from "@shared/user"; import { User } from "@shared/user";
import { useActiveAccount } from "@stores/accounts"; import { useActiveAccount } from "@stores/accounts";
import { useQuery } from "@tanstack/react-query";
import { parser } from "@utils/parser"; import { parser } from "@utils/parser";
import useSWR from "swr";
const fetcher = ([, id]) => getNoteByID(id);
export function ThreadBlock({ params }: { params: any }) { export function ThreadBlock({ params }: { params: any }) {
const { data } = useSWR(["thread", params.content], fetcher); const { status, data, isFetching } = useQuery(
const removeBlock = useActiveAccount((state: any) => state.removeBlock); ["thread", params.content],
async () => {
return await getNoteByID(params.content);
},
);
const content = data ? parser(data) : null; const content = data ? parser(data) : null;
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
const close = () => { const close = () => {
removeBlock(params.id, false); 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"> <div className="shrink-0 w-[400px] border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => close()} /> <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"> <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="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20"> <div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />

View File

@@ -3,18 +3,11 @@ import { FeedBlock } from "@app/space/components/blocks/feed";
import { FollowingBlock } from "@app/space/components/blocks/following"; import { FollowingBlock } from "@app/space/components/blocks/following";
import { ImageBlock } from "@app/space/components/blocks/image"; import { ImageBlock } from "@app/space/components/blocks/image";
import { ThreadBlock } from "@app/space/components/blocks/thread"; import { ThreadBlock } from "@app/space/components/blocks/thread";
import { useActiveAccount } from "@stores/accounts"; import { getBlocks } from "@libs/storage";
import { useEffect } from "react";
export function Page() { const blocks = await getBlocks();
const blocks = useActiveAccount((state: any) => state.blocks);
const fetchBlocks = useActiveAccount((state: any) => state.fetchBlocks);
useEffect(() => {
if (blocks !== null) return;
fetchBlocks();
}, [fetchBlocks]);
export function SpaceScreen() {
return ( return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide"> <div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<FollowingBlock block={1} /> <FollowingBlock block={1} />

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -1,16 +1,20 @@
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { compactNumber } from "@utils/number"; import { compactNumber } from "@utils/number";
import { shortenKey } from "@utils/shortenKey"; 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 }) { export function Profile({ data }: { data: any }) {
const { data: userStats, error } = useSWR( const {
`https://api.nostr.band/v0/stats/profile/${data.pubkey}`, status,
fetcher, 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 embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
const profile = embedProfile; const profile = embedProfile;
@@ -47,8 +51,7 @@ export function Profile({ data }: { data: any }) {
</p> </p>
</div> </div>
<div className="mt-8"> <div className="mt-8">
{error && <p>Failed to fetch user stats</p>} {status === "loading" || isFetching ? (
{!userStats ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
<div className="w-full flex items-center gap-8"> <div className="w-full flex items-center gap-8">

View File

@@ -1,22 +1,22 @@
import { Note } from "@shared/notes/note"; import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton"; import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar"; import { TitleBar } from "@shared/titleBar";
import useSWR from "swr"; import { useQuery } from "@tanstack/react-query";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function TrendingNotes() { export function TrendingNotes() {
const { data, error } = useSWR( const { status, data, isFetching } = useQuery(
"https://api.nostr.band/v0/trending/notes", ["trending-notes"],
fetcher, async () => {
const res = await fetch("https://api.nostr.band/v0/trending/notes");
return res.json();
},
); );
return ( return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900"> <div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
<TitleBar title="Trending Posts" /> <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"> <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>} {status === "loading" || isFetching ? (
{!data ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20"> <div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />

View File

@@ -1,22 +1,22 @@
import { Profile } from "@app/trending/components/profile"; import { Profile } from "@app/trending/components/profile";
import { NoteSkeleton } from "@shared/notes/skeleton"; import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar"; import { TitleBar } from "@shared/titleBar";
import useSWR from "swr"; import { useQuery } from "@tanstack/react-query";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function TrendingProfiles() { export function TrendingProfiles() {
const { data, error } = useSWR( const { status, data, isFetching } = useQuery(
"https://api.nostr.band/v0/trending/profiles", ["trending-profiles"],
fetcher, async () => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles");
return res.json();
},
); );
return ( return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900"> <div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
<TitleBar title="Trending Profiles" /> <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"> <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>} {status === "loading" || isFetching ? (
{!data ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20"> <div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />

View File

@@ -1,7 +1,7 @@
import { TrendingNotes } from "@app/trending/components/trendingNotes"; import { TrendingNotes } from "@app/trending/components/trendingNotes";
import { TrendingProfiles } from "@app/trending/components/trendingProfiles"; import { TrendingProfiles } from "@app/trending/components/trendingProfiles";
export function Page() { export function TrendingScreen() {
return ( return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide"> <div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<TrendingProfiles /> <TrendingProfiles />

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -3,27 +3,30 @@ import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts"; import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date"; import { dateToUnix } from "@utils/date";
import { usePageContext } from "@utils/hooks/usePageContext"; import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile"; import { useProfile } from "@utils/hooks/useProfile";
import { compactNumber } from "@utils/number"; import { compactNumber } from "@utils/number";
import { shortenKey } from "@utils/shortenKey"; import { shortenKey } from "@utils/shortenKey";
import { useContext } from "react"; 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 UserScreen() {
export function Page() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const pageContext = usePageContext(); const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search; const searchParams: any = pageContext.urlParsed.search;
const pubkey = searchParams.pubkey || ""; const pubkey = searchParams.pubkey || "";
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
const { data: userStats, error } = useSWR( const { data: userStats, error } = useQuery(["user", pubkey], async () => {
`https://api.nostr.band/v0/stats/profile/${pubkey}`, const res = await fetch(
fetcher, `https://api.nostr.band/v0/stats/profile/${pubkey}`,
); );
if (res.ok) {
return await res.json();
}
});
const account = useActiveAccount((state: any) => state.account); const account = useActiveAccount((state: any) => state.account);
const follows = account ? JSON.parse(account.follows) : []; const follows = account ? JSON.parse(account.follows) : [];
@@ -180,12 +183,12 @@ export function Page() {
Follow Follow
</button> </button>
)} )}
<a <Link
href={`/app/chat?pubkey=${pubkey}`} 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" 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 Message
</a> </Link>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -18,8 +18,14 @@ export async function connect(): Promise<Database> {
// get active account // get active account
export async function getActiveAccount() { export async function getActiveAccount() {
const db = await connect(); const db = await connect();
const result = await db.select("SELECT * FROM accounts WHERE is_active = 1;"); const result: any = await db.select(
return result[0]; "SELECT * FROM accounts WHERE is_active = 1;",
);
if (result.length > 0) {
return result[0];
} else {
return null;
}
} }
// get all accounts // get all accounts
@@ -189,9 +195,10 @@ export async function createNote(
// get note replies // get note replies
export async function getReplies(parent_id: string) { export async function getReplies(parent_id: string) {
const db = await connect(); 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;`, `SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`,
); );
return result;
} }
// create reply note // create reply note
@@ -214,7 +221,10 @@ export async function createReplyNote(
// get all channels // get all channels
export async function getChannels() { export async function getChannels() {
const db = await connect(); 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 // get channel by id
@@ -230,13 +240,15 @@ export async function getChannel(id: string) {
export async function createChannel( export async function createChannel(
event_id: string, event_id: string,
pubkey: string, pubkey: string,
metadata: string, name: string,
picture: string,
about: string,
created_at: number, created_at: number,
) { ) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
"INSERT OR IGNORE INTO channels (event_id, pubkey, metadata, created_at) VALUES (?, ?, ?, ?);", "INSERT OR IGNORE INTO channels (event_id, pubkey, name, picture, about, created_at) VALUES (?, ?, ?, ?, ?, ?);",
[event_id, pubkey, metadata, created_at], [event_id, pubkey, name, picture, about, created_at],
); );
} }
@@ -279,17 +291,19 @@ export async function getChannelMessages(channel_id: string) {
// get channel users // get channel users
export async function getChannelUsers(channel_id: string) { export async function getChannelUsers(channel_id: string) {
const db = await connect(); 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}";`, `SELECT DISTINCT pubkey FROM channel_messages WHERE channel_id = "${channel_id}";`,
); );
return result;
} }
// get all chats by pubkey // get all chats by pubkey
export async function getChatsByPubkey(pubkey: string) { export async function getChatsByPubkey(pubkey: string) {
const db = await connect(); 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;`, `SELECT DISTINCT sender_pubkey FROM chats WHERE receiver_pubkey = "${pubkey}" ORDER BY created_at DESC;`,
); );
return result;
} }
// get chat messages // get chat messages
@@ -390,11 +404,13 @@ export async function updateItemInBlacklist(content: string, status: number) {
} }
// get all blocks // get all blocks
export async function getBlocks(account_id: number) { export async function getBlocks() {
const db = await connect(); const db = await connect();
return await db.select( const activeAccount = await getActiveAccount();
`SELECT * FROM blocks WHERE account_id <= "${account_id}";`, const result: any = await db.select(
`SELECT * FROM blocks WHERE account_id <= "${activeAccount.id}";`,
); );
return result;
} }
// create block // create block

6
src/main.tsx Normal file
View 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 />);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,25 @@
import { getLastLogin } from "@libs/storage";
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import { NetworkStatusIndicator } from "@shared/networkStatusIndicator"; import { NetworkStatusIndicator } from "@shared/networkStatusIndicator";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { useChannels } from "@stores/channels"; import { useChannels } from "@stores/channels";
import { useChatMessages, useChats } from "@stores/chats"; import { useChats } from "@stores/chats";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { useProfile } from "@utils/hooks/useProfile";
import { sendNativeNotification } from "@utils/notification"; import { sendNativeNotification } from "@utils/notification";
import { useContext } from "react"; import { useContext, useEffect } from "react";
import useSWRSubscription from "swr/subscription";
const lastLogin = await getLastLogin();
export function ActiveAccount({ data }: { data: any }) { export function ActiveAccount({ data }: { data: any }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const lastLogin = useActiveAccount((state: any) => state.lastLogin);
const notifyChat = useChats((state: any) => state.add); const notifyChat = useChats((state: any) => state.add);
const saveChat = useChatMessages((state: any) => state.add);
const notifyChannel = useChannels((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); const since = lastLogin > 0 ? lastLogin : Math.floor(Date.now() / 1000);
// subscribe to channel // subscribe to channel
const sub = ndk.subscribe( const sub = ndk.subscribe(
@@ -41,8 +40,6 @@ export function ActiveAccount({ data }: { data: any }) {
sendNativeNotification("Someone mention you"); sendNativeNotification("Someone mention you");
break; break;
case 4: case 4:
// save
saveChat(data.pubkey, event);
// update state // update state
notifyChat(event.pubkey); notifyChat(event.pubkey);
// send native notifiation // send native notifiation
@@ -62,16 +59,20 @@ export function ActiveAccount({ data }: { data: any }) {
return () => { return () => {
sub.stop(); sub.stop();
}; };
}); }, []);
return ( return (
<button type="button" className="relative inline-block h-9 w-9"> <button type="button" className="relative inline-block h-9 w-9">
<Image {status === "loading" ? (
src={user?.image} <div className="w-9 h-9 rounded bg-zinc-800 animate-pulse" />
fallback={DEFAULT_AVATAR} ) : (
alt={data.npub} <Image
className="h-9 w-9 rounded object-cover" src={user.image}
/> fallback={DEFAULT_AVATAR}
alt={data.npub}
className="h-9 w-9 rounded object-cover"
/>
)}
<NetworkStatusIndicator /> <NetworkStatusIndicator />
</button> </button>
); );

View File

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

View File

@@ -1,12 +1,15 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons"; import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons";
import { useNavigate } from "react-router-dom";
export function AppHeader({ reverse }: { reverse?: boolean }) { export function AppHeader({ reverse }: { reverse?: boolean }) {
const navigate = useNavigate();
const goBack = () => { const goBack = () => {
window.history.back(); navigate(-1);
}; };
const goForward = () => { const goForward = () => {
window.history.forward(); navigate(1);
}; };
return ( return (

16
src/shared/appLayout.tsx Normal file
View 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>
);
}

View File

@@ -1,20 +1,18 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons"; 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 platformName = await platform();
const { platform } = await import("@tauri-apps/api/os");
return await platform();
};
export function LayoutOnboarding({ children }: { children: React.ReactNode }) { export function AuthLayout() {
const { data: platform } = useSWR("platform", fetcher); const navigate = useNavigate();
const goBack = () => { const goBack = () => {
window.history.back(); navigate(-1);
}; };
const goForward = () => { const goForward = () => {
window.history.forward(); navigate(1);
}; };
return ( return (
@@ -22,7 +20,7 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
<div className="flex h-screen w-full flex-col"> <div className="flex h-screen w-full flex-col">
<div <div
data-tauri-drag-region 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 <div
data-tauri-drag-region data-tauri-drag-region
@@ -30,7 +28,7 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
> >
<div <div
className={`flex h-full items-center gap-2 ${ className={`flex h-full items-center gap-2 ${
platform === "darwin" ? "pl-[68px]" : "" platformName === "darwin" ? "pl-[68px]" : ""
}`} }`}
> >
<button <button
@@ -58,7 +56,9 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
</div> </div>
</div> </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>
</div> </div>
); );

View File

@@ -15,7 +15,8 @@ import { Fragment } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
export function Composer() { export function Composer() {
const account = useActiveAccount((state: any) => state.account); const account = useActiveAccount((state) => state.account);
const [toggle, open] = useComposer((state: any) => [ const [toggle, open] = useComposer((state: any) => [
state.toggleModal, state.toggleModal,
state.open, state.open,

View File

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

View File

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

View File

@@ -1,18 +1,19 @@
import { Transition } from "@headlessui/react"; import { Transition } from "@headlessui/react";
import { getAccounts, getActiveAccount } from "@libs/storage"; import { getActiveAccount } from "@libs/storage";
import { ActiveAccount } from "@shared/accounts/active"; import { ActiveAccount } from "@shared/accounts/active";
import { InactiveAccount } from "@shared/accounts/inactive"; import { VerticalDotsIcon } from "@shared/icons";
import { PlusIcon, VerticalDotsIcon } from "@shared/icons"; import { useQuery } from "@tanstack/react-query";
import { Link } from "@shared/link";
import { useState } from "react"; import { useState } from "react";
import useSWR from "swr"; import { Link } from "react-router-dom";
const allFetcher = () => getAccounts();
const fetcher = () => getActiveAccount();
export function MultiAccounts() { export function MultiAccounts() {
const { data: accounts }: any = useSWR("allAccounts", allFetcher); const {
const { data: activeAccount }: any = useSWR("activeAccount", fetcher); status,
data: activeAccount,
isFetching,
} = useQuery(["activeAccount"], async () => {
return await getActiveAccount();
});
const [open, setOpen] = useState(false); 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 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 justify-between">
<div className="flex items-center gap-2"> <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" /> <div className="group relative flex h-9 w-9 shrink animate-pulse items-center justify-center rounded-lg bg-zinc-900" />
) : ( ) : (
<ActiveAccount data={activeAccount} /> <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> </div>
<button <button
type="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" className="flex flex-col items-start justify-start gap-1 pt-1.5 border-t border-zinc-800 transform"
> >
<Link <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" className="w-full py-2 px-2 rounded hover:bg-zinc-800 text-zinc-100 text-start text-sm"
> >
Settings Settings
</Link> </Link>
<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" className="w-full py-2 px-2 rounded hover:bg-zinc-800 text-zinc-100 text-start text-sm"
> >
Logout Logout

View File

@@ -1,13 +1,14 @@
import { ChannelsList } from "@app/channel/components/list"; import { ChannelsList } from "@app/channel/components/list";
import { ChatsList } from "@app/chat/components/list"; import { ChatsList } from "@app/chat/components/list";
import { Disclosure } from "@headlessui/react"; import { Disclosure } from "@headlessui/react";
import { ActiveLink } from "@shared/activeLink";
import { AppHeader } from "@shared/appHeader"; import { AppHeader } from "@shared/appHeader";
import { Composer } from "@shared/composer/modal"; import { Composer } from "@shared/composer/modal";
import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from "@shared/icons"; import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from "@shared/icons";
import { MultiAccounts } from "@shared/multiAccounts"; 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 ( return (
<div <div
className={`relative flex w-[232px] flex-col gap-3 ${ className={`relative flex w-[232px] flex-col gap-3 ${
@@ -27,20 +28,28 @@ export function Navigation({ reverse }: { reverse?: boolean }) {
</h3> </h3>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<ActiveLink <NavLink
href="/app/space" to="/app/space"
className="flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200" className={({ isActive }) =>
activeClassName="bg-zinc-900/50" 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"> <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" /> <SpaceIcon width={12} height={12} className="text-zinc-100" />
</span> </span>
<span className="font-medium">Spaces</span> <span className="font-medium">Spaces</span>
</ActiveLink> </NavLink>
<ActiveLink <NavLink
href="/app/trending" to="/app/trending"
className="flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200" className={({ isActive }) =>
activeClassName="bg-zinc-900/50" 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"> <span className="inline-flex h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<TrendingIcon <TrendingIcon
@@ -50,7 +59,7 @@ export function Navigation({ reverse }: { reverse?: boolean }) {
/> />
</span> </span>
<span className="font-medium">Trending</span> <span className="font-medium">Trending</span>
</ActiveLink> </NavLink>
</div> </div>
</div> </div>
{/* Channels */} {/* Channels */}

View File

@@ -3,18 +3,19 @@ import { Kind1063 } from "@shared/notes/contents/kind1063";
import { NoteSkeleton } from "@shared/notes/skeleton"; import { NoteSkeleton } from "@shared/notes/skeleton";
import { User } from "@shared/user"; import { User } from "@shared/user";
import { useEvent } from "@utils/hooks/useEvent"; import { useEvent } from "@utils/hooks/useEvent";
import { parser } from "@utils/parser";
import { memo } from "react"; import { memo } from "react";
export const MentionNote = memo(function MentionNote({ id }: { id: string }) { 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; const kind1063 = data?.kind === 1063 ? data.tags : null;
return ( return (
<div className="mt-3 rounded-lg border border-zinc-800 px-3 py-3"> <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" /> <User pubkey={data.pubkey} time={data.created_at} size="small" />
<div className="mt-2"> <div className="mt-2">
@@ -37,8 +38,6 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
)} )}
</div> </div>
</> </>
) : (
<NoteSkeleton />
)} )}
</div> </div>
); );

View File

@@ -1,13 +1,13 @@
import { Link } from "@shared/link";
import { useProfile } from "@utils/hooks/useProfile"; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { shortenKey } from "@utils/shortenKey";
import { Link } from "react-router-dom";
export function MentionUser({ pubkey }: { pubkey: string }) { export function MentionUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
return ( return (
<Link <Link
href={`/app/user?pubkey=${pubkey}`} to={`/app/user/${pubkey}`}
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal" className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal"
> >
@{user?.name || user?.displayName || shortenKey(pubkey)} @{user?.name || user?.displayName || shortenKey(pubkey)}

View File

@@ -5,57 +5,9 @@ import { NoteReply } from "@shared/notes/metadata/reply";
import { NoteRepost } from "@shared/notes/metadata/repost"; import { NoteRepost } from "@shared/notes/metadata/repost";
import { NoteZap } from "@shared/notes/metadata/zap"; import { NoteZap } from "@shared/notes/metadata/zap";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { decode } from "light-bolt11-decoder"; import { decode } from "light-bolt11-decoder";
import { useContext } from "react"; 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({ export function NoteMetadata({
id, id,
@@ -67,11 +19,60 @@ export function NoteMetadata({
currentBlock?: number; currentBlock?: number;
}) { }) {
const ndk = useContext(RelayContext); 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 ( return (
<div className="inline-flex items-center w-full h-12 mt-2"> <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"> <div className="w-20 group inline-flex items-center gap-1.5">
<ReplyIcon <ReplyIcon

View File

@@ -4,21 +4,22 @@ import { NoteMetadata } from "@shared/notes/metadata";
import { NoteSkeleton } from "@shared/notes/skeleton"; import { NoteSkeleton } from "@shared/notes/skeleton";
import { User } from "@shared/user"; import { User } from "@shared/user";
import { useEvent } from "@utils/hooks/useEvent"; import { useEvent } from "@utils/hooks/useEvent";
import { parser } from "@utils/parser";
export function NoteParent({ export function NoteParent({
id, id,
currentBlock, currentBlock,
}: { id: string; currentBlock: number }) { }: { 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; const kind1063 = data?.kind === 1063 ? data.tags : null;
return ( return (
<div className="relative overflow-hidden flex flex-col pb-6"> <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" /> <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} /> <User pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-5 pl-[49px]"> <div className="-mt-5 pl-[49px]">
@@ -46,8 +47,6 @@ export function NoteParent({
/> />
</div> </div>
</> </>
) : (
<NoteSkeleton />
)} )}
</div> </div>
); );

View File

@@ -1,16 +1,26 @@
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import { useOpenGraph } from "@utils/hooks/useOpenGraph"; 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[] }) { export function LinkPreview({ urls }: { urls: string[] }) {
const domain = new URL(urls[0]); const domain = new URL(urls[0]);
const { data, error, isLoading } = useOpenGraph(urls[0]); const { status, data, error, isFetching } = useOpenGraph(urls[0]);
return ( return (
<div className="mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800"> <div className="mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
{error && <p>failed to load</p>} {error && <p>failed to load</p>}
{isLoading || !data ? ( {isFetching || status === "loading" ? (
<div className="flex flex-col"> <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="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-2/3 h-3 rounded bg-zinc-700 animate-pulse" />
<div className="w-3/4 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> </span>
</div> </div>
</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 <a
className="flex flex-col rounded-lg border border-transparent hover:border-fuchsia-900" 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" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{data["og:image"] && ( {isValidURL(data["og:image"]) ? (
<Image <Image
src={data["og:image"]} src={data["og:image"]}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW" fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]} alt={urls[0]}
className="w-full h-44 object-cover rounded-t-lg bg-white" 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"> <div className="flex flex-col gap-2 px-3 py-3">
<h5 className="leading-none font-medium text-zinc-200"> <h5 className="leading-none font-medium text-zinc-200">

View File

@@ -1,17 +1,9 @@
import { MediaOutlet, MediaPlayer } from "@vidstack/react";
export function VideoPreview({ urls }: { urls: string[] }) { export function VideoPreview({ urls }: { urls: string[] }) {
return ( return (
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(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" 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>
); );
} }

View File

@@ -10,8 +10,9 @@ import { useContext, useState } from "react";
export function NoteReplyForm({ id }: { id: string }) { export function NoteReplyForm({ id }: { id: string }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account); const account = useActiveAccount((state) => state.account);
const { user } = useProfile(account.npub);
const { status, user } = useProfile(account.npub);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
@@ -46,35 +47,41 @@ export function NoteReplyForm({ id }: { id: string }) {
/> />
</div> </div>
<div className="border-t border-zinc-800 w-full py-3 px-5"> <div className="border-t border-zinc-800 w-full py-3 px-5">
<div className="flex w-full items-center justify-between"> {status === "loading" ? (
<div className="inline-flex items-center gap-2"> <div>
<div className="relative h-9 w-9 shrink-0 rounded"> <p>Loading...</p>
<Image </div>
src={user?.image} ) : (
fallback={DEFAULT_AVATAR} <div className="flex w-full items-center justify-between">
alt={account.npub} <div className="inline-flex items-center gap-2">
className="h-9 w-9 rounded-md bg-white object-cover" <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>
<div> <div className="flex items-center gap-2">
<p className="mb-px leading-none text-sm text-zinc-400"> <Button
Reply as onClick={() => submitEvent()}
</p> disabled={value.length === 0 ? true : false}
<p className="leading-none text-sm font-medium text-zinc-100"> preset="publish"
{user?.nip05 || user?.name} >
</p> Reply
</Button>
</div> </div>
</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>
</div> </div>
); );

View File

@@ -2,12 +2,12 @@ import { getReplies } from "@libs/storage";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { EmptyIcon } from "@shared/icons"; import { EmptyIcon } from "@shared/icons";
import { Reply } from "@shared/notes/replies/item"; import { Reply } from "@shared/notes/replies/item";
import useSWR from "swr"; import { useQuery } from "@tanstack/react-query";
const fetcher = ([, id]) => getReplies(id);
export function RepliesList({ parent_id }: { parent_id: string }) { 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 ( return (
<div className="mt-5"> <div className="mt-5">

View File

@@ -4,7 +4,6 @@ import { NoteMetadata } from "@shared/notes/metadata";
import { NoteSkeleton } from "@shared/notes/skeleton"; import { NoteSkeleton } from "@shared/notes/skeleton";
import { User } from "@shared/user"; import { User } from "@shared/user";
import { useEvent } from "@utils/hooks/useEvent"; import { useEvent } from "@utils/hooks/useEvent";
import { parser } from "@utils/parser";
import { getRepostID } from "@utils/transform"; import { getRepostID } from "@utils/transform";
import { LumeEvent } from "@utils/types"; import { LumeEvent } from "@utils/types";
@@ -13,14 +12,16 @@ export function Repost({
currentBlock, currentBlock,
}: { event: LumeEvent; currentBlock?: number }) { }: { event: LumeEvent; currentBlock?: number }) {
const repostID = getRepostID(event.tags); 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; const kind1063 = data?.kind === 1063 ? data.tags : null;
return ( return (
<div className="relative overflow-hidden flex flex-col mt-12"> <div className="relative overflow-hidden flex flex-col mt-12">
{data ? ( {isFetching || status === "loading" ? (
<NoteSkeleton />
) : (
<> <>
<User pubkey={data.pubkey} time={data.created_at} /> <User pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-5 pl-[49px]"> <div className="-mt-5 pl-[49px]">
@@ -48,8 +49,6 @@ export function Repost({
/> />
</div> </div>
</> </>
) : (
<NoteSkeleton />
)} )}
</div> </div>
); );

13
src/shared/protected.tsx Normal file
View 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;
}

View File

@@ -1,9 +1,12 @@
import { initNDK } from "@libs/ndk"; import { initNDK } from "@libs/ndk";
import NDK from "@nostr-dev-kit/ndk"; import NDK from "@nostr-dev-kit/ndk";
import { FULL_RELAYS } from "@stores/constants";
import { createContext } from "react"; import { createContext } from "react";
export const RelayContext = createContext<NDK>(null); 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 }) { export function RelayProvider({ children }: { children: React.ReactNode }) {
return <RelayContext.Provider value={ndk}>{children}</RelayContext.Provider>; return <RelayContext.Provider value={ndk}>{children}</RelayContext.Provider>;

View File

@@ -1,12 +1,12 @@
import { Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import { Link } from "@shared/link";
import { DEFAULT_AVATAR } from "@stores/constants"; import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { shortenKey } from "@utils/shortenKey";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from "react"; import { Fragment } from "react";
import { Link } from "react-router-dom";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -96,13 +96,13 @@ export function User({
</div> </div>
<div className="flex items-center gap-2 px-3 py-3"> <div className="flex items-center gap-2 px-3 py-3">
<Link <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" 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 View profile
</Link> </Link>
<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" 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 Message

View File

@@ -7,6 +7,5 @@ export const OPENGRAPH_KEY = "9EJG4SY-19Q4M5J-H8R29C9-091XPCC";
export const FULL_RELAYS = [ export const FULL_RELAYS = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://relay.nostr.band/all", "wss://relay.nostr.band/all",
"wss://relay.nostrich.land",
"wss://relay.nostrgraph.net", "wss://relay.nostrgraph.net",
]; ];

17
src/stores/onboarding.tsx Normal file
View 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),
},
),
);

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

View File

@@ -1,31 +1,40 @@
import { createNote, getNoteByID } from "@libs/storage"; import { createNote, getNoteByID } from "@libs/storage";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { parser } from "@utils/parser";
import { useContext } from "react"; 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) { export function useEvent(id: string) {
const ndk = useContext(RelayContext); 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 };
} }

View File

@@ -1,28 +1,49 @@
import { OPENGRAPH_KEY } from "@stores/constants"; import { OPENGRAPH_KEY } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/api/http"; 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) { export function useOpenGraph(url: string) {
const { data, error, isLoading } = useSWR( const { status, data, error, isFetching } = useQuery(
`https://skrape.dev/api/opengraph/?url=${url}&key=${OPENGRAPH_KEY}`, ["preview", url],
fetcher, 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 { return {
data: data, status,
error: error, data,
isLoading: isLoading, error,
isFetching,
}; };
} }

View File

@@ -1,44 +1,38 @@
import { createPleb, getPleb } from "@libs/storage"; import { createPleb, getPleb } from "@libs/storage";
import NDK from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { useContext } from "react"; import { useContext } from "react";
import useSWR from "swr";
const fetcher = async ([, ndk, key]) => { export function useProfile(id: string) {
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) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const { data, error, isLoading } = useSWR(["profile", ndk, key], fetcher, { const {
revalidateIfStale: false, status,
revalidateOnFocus: false, data: user,
revalidateOnReconnect: false, 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 { return { status, user, error, isFetching };
user: data,
isLoading,
isError: error,
};
} }

View File

@@ -1,8 +1,8 @@
import { Link } from "@shared/link";
import { MentionUser } from "@shared/notes/mentions/user"; import { MentionUser } from "@shared/notes/mentions/user";
import destr from "destr"; import destr from "destr";
import getUrls from "get-urls"; import getUrls from "get-urls";
import { parseReferences } from "nostr-tools"; import { parseReferences } from "nostr-tools";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
function isJsonString(str) { function isJsonString(str) {
@@ -50,17 +50,33 @@ export function parser(event: any) {
// image url // image url
content.images.push(url); content.images.push(url);
// remove url from original content // 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)$/)) { } else if (url.match(/\.(mp4|webm|mov|ogv|avi|mp3)$/)) {
// video // video
content.videos.push(url); content.videos.push(url);
// remove url from original content // remove url from original content
content.parsed = content.parsed.replace(url, ""); content.parsed = reactStringReplace(content.parsed, url, () => null);
} else { } else {
// push to store if (content.links.length < 1) {
content.links.push(url); // push to store
// remove url from original content content.links.push(url);
content.parsed = content.parsed.replace(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; const event = item.event;
if (event) { if (event) {
content.notes.push(event.id); content.notes.push(event.id);
content.parsed = content.parsed.replace(item.text, ""); content.parsed = reactStringReplace(
content.parsed,
item.text,
() => null,
);
} }
if (profile) { if (profile) {
content.parsed = reactStringReplace( content.parsed = reactStringReplace(
@@ -85,7 +105,7 @@ export function parser(event: any) {
content.parsed = reactStringReplace(content.parsed, /#(\w+)/g, (match, i) => ( content.parsed = reactStringReplace(content.parsed, /#(\w+)/g, (match, i) => (
<Link <Link
key={match + i} key={match + i}
href={`/search/${match}`} to={`/search/${match}`}
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal" className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal"
> >
#{match} #{match}

View File

@@ -1,57 +1,10 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'], content: ["./src/**/*.{js,ts,jsx,tsx}", "index.html"],
darkMode: 'class', darkMode: "class",
theme: { theme: {
extend: { extend: {},
boxShadow: { },
input: ` plugins: [require("@tailwindcss/typography")],
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')],
}; };

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