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

View File

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

View File

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

View File

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

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

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

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

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 { Link } from "react-router-dom";
export function Page() {
export function WelcomeScreen() {
return (
<div className="w-full h-full grid grid-cols-12 gap-4 px-4 py-4">
<div className="col-span-5 border-t border-zinc-800/50 bg-zinc-900 rounded-xl flex flex-col">
@ -19,20 +20,20 @@ export function Page() {
</h3>
</div>
<div className="mt-auto w-full flex flex-col gap-2 px-4 py-4">
<a
href="/app/auth/import"
<Link
to="/auth/import"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600"
>
<span className="w-5" />
<span>Login with private key</span>
<ArrowRightCircleIcon className="w-5 h-5" />
</a>
<a
href="/app/auth/create"
</Link>
<Link
to="/auth/create"
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg px-6 font-medium text-zinc-200 bg-zinc-800 hover:bg-zinc-700"
>
Create new key
</a>
</Link>
</div>
</div>
<div

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { ChannelMessageForm } from "@app/channel/components/messages/form";
import { ChannelMetadata } from "@app/channel/components/metadata";
import { RelayContext } from "@shared/relayProvider";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix, getHourAgo } from "@utils/date";
import { usePageContext } from "@utils/hooks/usePageContext";
import { LumeEvent } from "@utils/types";
import { useCallback, useContext, useEffect, useRef } from "react";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
} from "react";
import { useParams } from "react-router-dom";
import { Virtuoso } from "react-virtuoso";
import useSWRSubscription from "swr/subscription";
const now = new Date();
@ -42,13 +47,11 @@ const Empty = (
</div>
);
export function Page() {
export function ChannelScreen() {
const ndk = useContext(RelayContext);
const pageContext = usePageContext();
const virtuosoRef = useRef(null);
const searchParams: any = pageContext.urlParsed.search;
const channelID = searchParams.id;
const { id } = useParams();
const [messages, fetchMessages, addMessage, clearMessages] =
useChannelMessages((state: any) => [
@ -58,36 +61,30 @@ export function Page() {
state.clear,
]);
useSWRSubscription(
channelID ? ["channelMessagesSubscribe", channelID] : null,
() => {
// subscribe to channel
const sub = ndk.subscribe(
{
"#e": [channelID],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false },
);
sub.addListener("event", (event: LumeEvent) => {
addMessage(channelID, event);
});
return () => {
sub.stop();
};
},
);
useLayoutEffect(() => {
fetchMessages(id);
}, [fetchMessages]);
useEffect(() => {
fetchMessages(channelID);
// subscribe to channel
const sub = ndk.subscribe(
{
"#e": [id],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false },
);
sub.addListener("event", (event: LumeEvent) => {
addMessage(id, event);
});
return () => {
clearMessages();
sub.stop();
};
}, [fetchMessages]);
}, []);
const itemContent: any = useCallback(
(index: string | number) => {
@ -135,7 +132,7 @@ export function Page() {
/>
)}
<div className="w-full inline-flex shrink-0 px-5 py-3 border-t border-zinc-800">
<ChannelMessageForm channelID={channelID} />
<ChannelMessageForm channelID={id} />
</div>
</div>
</div>
@ -146,8 +143,8 @@ export function Page() {
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
<div className="p-3 flex flex-col gap-3">
<ChannelMetadata id={channelID} />
<ChannelMembers id={channelID} />
<ChannelMetadata id={id} />
<ChannelMembers id={id} />
</div>
</div>
</div>

View File

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

View File

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

View File

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

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

View File

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

View File

@ -3,14 +3,15 @@ import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { nip19 } from "nostr-tools";
import { navigate } from "vite-plugin-ssr/client/router";
import { useNavigate } from "react-router-dom";
export function ChatSidebar({ pubkey }: { pubkey: string }) {
const navigate = useNavigate();
const { user } = useProfile(pubkey);
const viewProfile = () => {
const pubkey = nip19.decode(user.npub).data;
navigate(`/app/user?pubkey=${pubkey}`);
navigate(`/app/user/${pubkey}`);
};
return (

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile";
import { compactNumber } from "@utils/number";
import { shortenKey } from "@utils/shortenKey";
import { useContext } from "react";
import useSWR from "swr";
import { Link } from "react-router-dom";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function Page() {
export function UserScreen() {
const ndk = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pubkey = searchParams.pubkey || "";
const { user } = useProfile(pubkey);
const { data: userStats, error } = useSWR(
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
fetcher,
);
const { data: userStats, error } = useQuery(["user", pubkey], async () => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
);
if (res.ok) {
return await res.json();
}
});
const account = useActiveAccount((state: any) => state.account);
const follows = account ? JSON.parse(account.follows) : [];
@ -180,12 +183,12 @@ export function Page() {
Follow
</button>
)}
<a
href={`/app/chat?pubkey=${pubkey}`}
<Link
to={`/app/chat/${pubkey}`}
className="inline-flex w-44 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
>
Message
</a>
</Link>
</div>
</div>
</div>

View File

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

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

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 { useNavigate } from "react-router-dom";
export function AppHeader({ reverse }: { reverse?: boolean }) {
const navigate = useNavigate();
const goBack = () => {
window.history.back();
navigate(-1);
};
const goForward = () => {
window.history.forward();
navigate(1);
};
return (

16
src/shared/appLayout.tsx Normal file
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 useSWR from "swr";
import { platform } from "@tauri-apps/api/os";
import { Outlet, useNavigate } from "react-router-dom";
const fetcher = async () => {
const { platform } = await import("@tauri-apps/api/os");
return await platform();
};
const platformName = await platform();
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
const { data: platform } = useSWR("platform", fetcher);
export function AuthLayout() {
const navigate = useNavigate();
const goBack = () => {
window.history.back();
navigate(-1);
};
const goForward = () => {
window.history.forward();
navigate(1);
};
return (
@ -22,7 +20,7 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<div
data-tauri-drag-region
@ -30,7 +28,7 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
>
<div
className={`flex h-full items-center gap-2 ${
platform === "darwin" ? "pl-[68px]" : ""
platformName === "darwin" ? "pl-[68px]" : ""
}`}
>
<button
@ -58,7 +56,9 @@ export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
</div>
</div>
</div>
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
<div className="relative flex min-h-0 w-full flex-1">
<Outlet />
</div>
</div>
</div>
);

View File

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

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 { getAccounts, getActiveAccount } from "@libs/storage";
import { getActiveAccount } from "@libs/storage";
import { ActiveAccount } from "@shared/accounts/active";
import { InactiveAccount } from "@shared/accounts/inactive";
import { PlusIcon, VerticalDotsIcon } from "@shared/icons";
import { Link } from "@shared/link";
import { VerticalDotsIcon } from "@shared/icons";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import useSWR from "swr";
const allFetcher = () => getAccounts();
const fetcher = () => getActiveAccount();
import { Link } from "react-router-dom";
export function MultiAccounts() {
const { data: accounts }: any = useSWR("allAccounts", allFetcher);
const { data: activeAccount }: any = useSWR("activeAccount", fetcher);
const {
status,
data: activeAccount,
isFetching,
} = useQuery(["activeAccount"], async () => {
return await getActiveAccount();
});
const [open, setOpen] = useState(false);
@ -24,28 +25,11 @@ export function MultiAccounts() {
<div className="flex flex-col gap-2 rounded-xl p-2 border-t border-zinc-800/50 bg-zinc-900/80 backdrop-blur-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{!activeAccount ? (
{status === "loading" || isFetching ? (
<div className="group relative flex h-9 w-9 shrink animate-pulse items-center justify-center rounded-lg bg-zinc-900" />
) : (
<ActiveAccount data={activeAccount} />
)}
{!accounts ? (
<div className="group relative flex h-9 w-9 shrink animate-pulse items-center justify-center rounded-lg bg-zinc-900" />
) : (
accounts.map((account: { is_active: number; pubkey: string }) => (
<InactiveAccount key={account.pubkey} data={account} />
))
)}
<button
type="button"
className="group relative flex h-9 w-9 shrink items-center justify-center rounded border border-dashed border-zinc-600 hover:border-zinc-400"
>
<PlusIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-zinc-100"
/>
</button>
</div>
<button
type="button"
@ -66,13 +50,13 @@ export function MultiAccounts() {
className="flex flex-col items-start justify-start gap-1 pt-1.5 border-t border-zinc-800 transform"
>
<Link
href="/app/settings"
to="/app/settings"
className="w-full py-2 px-2 rounded hover:bg-zinc-800 text-zinc-100 text-start text-sm"
>
Settings
</Link>
<Link
href="/app/logout"
to="/app/logout"
className="w-full py-2 px-2 rounded hover:bg-zinc-800 text-zinc-100 text-start text-sm"
>
Logout

View File

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

View File

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

View File

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

View File

@ -5,57 +5,9 @@ import { NoteReply } from "@shared/notes/metadata/reply";
import { NoteRepost } from "@shared/notes/metadata/repost";
import { NoteZap } from "@shared/notes/metadata/zap";
import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { decode } from "light-bolt11-decoder";
import { useContext } from "react";
import useSWR from "swr";
const fetcher = async ([, ndk, id]) => {
let replies = 0;
let reposts = 0;
let zap = 0;
const filter: NDKFilter = {
"#e": [id],
kinds: [1, 6, 9735],
};
const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => {
switch (event.kind) {
case 1:
replies += 1;
createReplyNote(
id,
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
break;
case 6:
reposts += 1;
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === "bolt11")[1];
if (bolt11) {
const decoded = decode(bolt11);
const amount = decoded.sections.find(
(item) => item.name === "amount",
);
const sats = amount.value / 1000;
zap += sats;
}
break;
}
default:
break;
}
});
return { replies, reposts, zap };
};
export function NoteMetadata({
id,
@ -67,11 +19,60 @@ export function NoteMetadata({
currentBlock?: number;
}) {
const ndk = useContext(RelayContext);
const { data, isLoading } = useSWR(["note-metadata", ndk, id], fetcher);
const { status, data, isFetching } = useQuery(
["note-metadata", id],
async () => {
let replies = 0;
let reposts = 0;
let zap = 0;
const filter: NDKFilter = {
"#e": [id],
kinds: [1, 6, 9735],
};
const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => {
switch (event.kind) {
case 1:
replies += 1;
createReplyNote(
id,
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
break;
case 6:
reposts += 1;
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === "bolt11")[1];
if (bolt11) {
const decoded = decode(bolt11);
const amount = decoded.sections.find(
(item) => item.name === "amount",
);
const sats = amount.value / 1000;
zap += sats;
}
break;
}
default:
break;
}
});
return { replies, reposts, zap };
},
);
return (
<div className="inline-flex items-center w-full h-12 mt-2">
{!data || isLoading ? (
{status === "loading" || isFetching ? (
<>
<div className="w-20 group inline-flex items-center gap-1.5">
<ReplyIcon

View File

@ -4,21 +4,22 @@ import { NoteMetadata } from "@shared/notes/metadata";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { User } from "@shared/user";
import { useEvent } from "@utils/hooks/useEvent";
import { parser } from "@utils/parser";
export function NoteParent({
id,
currentBlock,
}: { id: string; currentBlock: number }) {
const data = useEvent(id);
const { status, data, isFetching } = useEvent(id);
const kind1 = data?.kind === 1 ? parser(data) : null;
const kind1 = data?.kind === 1 ? data.content : null;
const kind1063 = data?.kind === 1063 ? data.tags : null;
return (
<div className="relative overflow-hidden flex flex-col pb-6">
<div className="absolute left-[18px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
{data ? (
{isFetching || status === "loading" ? (
<NoteSkeleton />
) : (
<>
<User pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-5 pl-[49px]">
@ -46,8 +47,6 @@ export function NoteParent({
/>
</div>
</>
) : (
<NoteSkeleton />
)}
</div>
);

View File

@ -1,16 +1,26 @@
import { Image } from "@shared/image";
import { useOpenGraph } from "@utils/hooks/useOpenGraph";
function isValidURL(string: string) {
let url: URL;
try {
url = new URL(string);
} catch (_) {
return false;
}
return true;
}
export function LinkPreview({ urls }: { urls: string[] }) {
const domain = new URL(urls[0]);
const { data, error, isLoading } = useOpenGraph(urls[0]);
const { status, data, error, isFetching } = useOpenGraph(urls[0]);
return (
<div className="mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
{error && <p>failed to load</p>}
{isLoading || !data ? (
{isFetching || status === "loading" ? (
<div className="flex flex-col">
<div className="w-full h-16 bg-zinc-700 animate-pulse" />
<div className="w-full h-44 bg-zinc-700 animate-pulse" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="w-2/3 h-3 rounded bg-zinc-700 animate-pulse" />
<div className="w-3/4 h-3 rounded bg-zinc-700 animate-pulse" />
@ -19,6 +29,20 @@ export function LinkPreview({ urls }: { urls: string[] }) {
</span>
</div>
</div>
) : !data ? (
<a
className="flex flex-col px-3 py-3 rounded-lg border border-transparent hover:border-fuchsia-900"
href={urls[0]}
target="_blank"
rel="noreferrer"
>
<p className="leading-none text-sm text-zinc-400 line-clamp-3">
Can't fetch open graph, click to open website directly
</p>
<span className="mt-2.5 leading-none text-sm text-zinc-500">
{domain.hostname}
</span>
</a>
) : (
<a
className="flex flex-col rounded-lg border border-transparent hover:border-fuchsia-900"
@ -26,13 +50,20 @@ export function LinkPreview({ urls }: { urls: string[] }) {
target="_blank"
rel="noreferrer"
>
{data["og:image"] && (
{isValidURL(data["og:image"]) ? (
<Image
src={data["og:image"]}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]}
className="w-full h-44 object-cover rounded-t-lg bg-white"
/>
) : (
<Image
src="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]}
className="w-full h-44 object-cover rounded-t-lg bg-white"
/>
)}
<div className="flex flex-col gap-2 px-3 py-3">
<h5 className="leading-none font-medium text-zinc-200">

View File

@ -1,17 +1,9 @@
import { MediaOutlet, MediaPlayer } from "@vidstack/react";
export function VideoPreview({ urls }: { urls: string[] }) {
return (
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="relative mt-3 max-w-[420px] flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
>
{urls.map((url: string) => (
<MediaPlayer key={url} src={urls[0]} poster="" controls>
<MediaOutlet />
</MediaPlayer>
))}
</div>
/>
);
}

View File

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

View File

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

View File

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

13
src/shared/protected.tsx Normal file
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 NDK from "@nostr-dev-kit/ndk";
import { FULL_RELAYS } from "@stores/constants";
import { createContext } from "react";
export const RelayContext = createContext<NDK>(null);
const ndk = await initNDK();
const ndk = new NDK({ explicitRelayUrls: FULL_RELAYS });
await ndk.connect();
export function RelayProvider({ children }: { children: React.ReactNode }) {
return <RelayContext.Provider value={ndk}>{children}</RelayContext.Provider>;

View File

@ -1,12 +1,12 @@
import { Popover, Transition } from "@headlessui/react";
import { Image } from "@shared/image";
import { Link } from "@shared/link";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from "react";
import { Link } from "react-router-dom";
dayjs.extend(relativeTime);
@ -96,13 +96,13 @@ export function User({
</div>
<div className="flex items-center gap-2 px-3 py-3">
<Link
href={`/app/user?pubkey=${pubkey}`}
to={`/app/user/${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-700 hover:bg-fuchsia-500 text-sm font-medium"
>
View profile
</Link>
<Link
href={`/app/chat?pubkey=${pubkey}`}
to={`/app/chat/${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-700 hover:bg-fuchsia-500 text-sm font-medium"
>
Message

View File

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

17
src/stores/onboarding.tsx Normal file
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 { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { parser } from "@utils/parser";
import { useContext } from "react";
import useSWR from "swr";
const fetcher = async ([, ndk, id]) => {
const result = await getNoteByID(id);
if (result) {
return result;
} else {
const event = await ndk.fetchEvent(id);
await createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
event["event_id"] = event.id;
return event;
}
};
export function useEvent(id: string) {
const ndk = useContext(RelayContext);
const { data } = useSWR(["note", ndk, id], fetcher);
const { status, data, error, isFetching } = useQuery(
["note", id],
async () => {
const result = await getNoteByID(id);
if (result) {
result["content"] = parser(result);
return result;
} else {
const event = await ndk.fetchEvent(id);
await createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
event["event_id"] = event.id;
// @ts-ignore
event["content"] = parser(event);
return event;
}
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
);
return data;
return { status, data, error, isFetching };
}

View File

@ -1,28 +1,49 @@
import { OPENGRAPH_KEY } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/api/http";
import useSWR from "swr";
const fetcher = async (url: string) => {
const result = await fetch(url, {
method: "GET",
timeout: 20,
});
if (result.ok) {
return result.data;
} else {
return null;
}
};
export function useOpenGraph(url: string) {
const { data, error, isLoading } = useSWR(
`https://skrape.dev/api/opengraph/?url=${url}&key=${OPENGRAPH_KEY}`,
fetcher,
const { status, data, error, isFetching } = useQuery(
["preview", url],
async () => {
const result = await fetch(
`https://skrape.dev/api/opengraph/?url=${url}&key=${OPENGRAPH_KEY}`,
{
method: "GET",
timeout: 10,
},
);
if (result.ok) {
if (Object.keys(result.data).length === 0) {
const origin = new URL(url).origin;
const result = await fetch(
`https://skrape.dev/api/opengraph/?url=${origin}&key=${OPENGRAPH_KEY}`,
{
method: "GET",
timeout: 10,
},
);
if (result.ok) {
return result.data;
}
} else {
return result.data;
}
} else {
return null;
}
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
);
return {
data: data,
error: error,
isLoading: isLoading,
status,
data,
error,
isFetching,
};
}

View File

@ -1,44 +1,38 @@
import { createPleb, getPleb } from "@libs/storage";
import NDK from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools";
import { useContext } from "react";
import useSWR from "swr";
const fetcher = async ([, ndk, key]) => {
let npub: string;
if (key.substring(0, 4) === "npub") {
npub = key;
} else {
npub = nip19.npubEncode(key);
}
const current = Math.floor(Date.now() / 1000);
const result = await getPleb(npub);
if (result && result.created_at + 86400 > current) {
return result;
} else {
const user = ndk.getUser({ npub });
await user.fetchProfile();
await createPleb(key, user.profile);
return user.profile;
}
};
export function useProfile(key: string) {
export function useProfile(id: string) {
const ndk = useContext(RelayContext);
const { data, error, isLoading } = useSWR(["profile", ndk, key], fetcher, {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
const {
status,
data: user,
error,
isFetching,
} = useQuery(["user", id], async () => {
let npub: string;
if (id.substring(0, 4) === "npub") {
npub = id;
} else {
npub = nip19.npubEncode(id);
}
const current = Math.floor(Date.now() / 1000);
const result = await getPleb(npub);
if (result && result.created_at + 86400 > current) {
return result;
} else {
const user = ndk.getUser({ npub });
await user.fetchProfile();
await createPleb(id, user.profile);
return user.profile;
}
});
return {
user: data,
isLoading,
isError: error,
};
return { status, user, error, isFetching };
}

View File

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

View File

@ -1,57 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
boxShadow: {
input: `
0px 1px 0px -1px var(--tw-shadow-color),
0px 1px 1px -1px var(--tw-shadow-color),
0px 1px 2px -1px var(--tw-shadow-color),
0px 2px 4px -2px var(--tw-shadow-color),
0px 3px 6px -3px var(--tw-shadow-color)
`,
highlight: `
inset 0px 0px 0px 1px var(--tw-shadow-color),
inset 0px 1px 0px var(--tw-shadow-color)
`,
popover: `0px 0px 7px rgba(0,0,0,0.52)`,
inner: `
0 2px 2px rgb(4 4 7 / 45%),
0 8px 24px rgb(4 4 7 / 60%)
`,
button: `
rgba(74, 4, 78, 0.5) 0px 2px 8px,
rgb(74, 4, 78) 0px 2px 4px,
rgb(74, 4, 78) 0px 0px 0px 1px,
rgba(255, 255, 255, 0.2) 0px 0px 0px 1px inset
`,
'mini-button': `
rgba(13, 16, 23, 0.36) 0px 2px 8px,
rgba(13, 16, 23, 0.36) 0px 2px 4px,
rgba(13, 16, 23, 0.36) 0px 0px 0px 1px,
rgba(255, 255, 255, 0.1) 0px 0px 0px 1px inset
`,
},
backgroundImage: {
fade: 'linear-gradient(120deg, #000, transparent 30%, transparent 70%, #000)',
},
keyframes: {
moveBg: {
'0%': { backgroundPosition: '50px' },
'20%': { backgroundPosition: '150px' },
'40%': { backgroundPosition: '250px' },
'60%': { backgroundPosition: '350px' },
'80%': { backgroundPosition: '450px' },
'100%': { backgroundPosition: '550px' },
},
},
animation: {
moveBg: 'moveBg 3s ease-in-out infinite alternate running forwards',
},
},
},
plugins: [require('@tailwindcss/typography')],
content: ["./src/**/*.{js,ts,jsx,tsx}", "index.html"],
darkMode: "class",
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};

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