Compare commits

...
128 Commits
Author SHA1 Message Date
reya f2b1458bd2 bump version & fix using nsecbunker with token 2023-12-07 18:49:55 +07:00
reya 0d43c13928 bump version 2023-12-07 11:55:49 +07:00
reya a42a2788ea fix nip-05 2023-12-06 10:15:41 +07:00
reya e30274dab3 fix typo 2023-12-06 09:07:32 +07:00
Ren AmamiyaandGitHub 740b7588bc Merge pull request #127 from luminous-devs/hotfix/2.2.1
v2.2.1
2023-12-06 08:09:44 +07:00
reya 24c2ed4eb2 update 2023-12-06 08:08:11 +07:00
reya 4006c0010e polish 2023-12-05 15:31:45 +07:00
reya 7decf264d7 polish nsecbunker 2023-12-05 14:25:44 +07:00
reya 482b218f74 improve error handling for useevent hook 2023-12-05 09:42:08 +07:00
reya e06b760e41 bump version 2023-12-04 14:14:50 +07:00
reya 7efc35f622 replace media chrome with vidstack 2023-12-04 14:02:07 +07:00
reya 8795923443 update 2023-12-04 13:36:16 +07:00
reya 4093821fd0 clean up 2023-12-04 12:47:29 +07:00
reya b19637bdb7 remove misconfigure in react query 2023-12-04 12:17:16 +07:00
reya 21e758ec13 update user component 2023-12-04 11:49:52 +07:00
reya 48ab123850 improve relay screen 2023-12-04 09:26:19 +07:00
reya a401070031 update dependencies 2023-12-04 08:46:42 +07:00
reya e5e4109e79 bump version 2023-12-03 08:38:32 +07:00
Ren AmamiyaandGitHub d62c814f33 Merge pull request #125 from luminous-devs/next
Lume v2.2.0
2023-12-03 08:37:37 +07:00
reya 2a92b7c202 polish 2023-12-03 08:34:44 +07:00
reya 255dcb43fe improve relay form 2023-12-02 17:53:45 +07:00
reya a528b646e3 add finish step to tutorial 2023-12-02 08:33:23 +07:00
reya fc35745c0d wip: tutorial 2023-12-01 15:45:43 +07:00
reya 9ddf3471ce fix nsecbunker 2023-12-01 08:23:46 +07:00
reya 8355ad6863 auto connect user relays 2023-11-30 20:15:54 +07:00
reya 217ac490b1 fix outbox 2023-11-30 19:22:00 +07:00
reya 092cf49227 improve relay connection 2023-11-30 18:19:24 +07:00
reya 5318f6c4cb clean up 2023-11-30 17:24:07 +07:00
reya 80f675cb54 improve notification and performance 2023-11-30 16:02:28 +07:00
reya 6f68c2762b add prefetch data 2023-11-30 10:35:08 +07:00
reya f4390b29e2 revamp onboarding and launching process 2023-11-30 09:38:58 +07:00
reya 00e4f9d357 clean up dependencies 2023-11-28 15:36:57 +07:00
reya d28d183620 Merge branch 'main' into next 2023-11-28 09:07:14 +07:00
reya 3c6c9c86d1 2.1.7 2023-11-28 09:00:46 +07:00
reya bcd079c88e update dependencies 2023-11-28 08:34:21 +07:00
Ren AmamiyaandGitHub d989d6ffad Merge pull request #123 from luminous-devs/feat/v2.1.6
Feat/v2.1.6
2023-11-27 12:01:25 +07:00
reya 5229458746 bump version 2023-11-27 09:56:43 +07:00
reya 2bfa1db816 update 2023-11-27 09:48:51 +07:00
reya 8439428ce1 fix crash on settings screen 2023-11-26 15:01:13 +07:00
reya 34dceef4a3 fix mention popup 2023-11-26 07:48:28 +07:00
Ren AmamiyaandGitHub 619bfb8dff Merge pull request #122 from luminous-devs/v2.1.4
v2.1.4
2023-11-26 07:22:13 +07:00
reya 7759851541 clean up 2023-11-26 07:21:24 +07:00
reya 9112c1c24a improve connection 2023-11-25 17:56:45 +07:00
reya 24b21a9451 update 2023-11-25 16:03:05 +07:00
reya 31a53b9c48 add @ suggestion popup 2023-11-25 15:41:18 +07:00
reya dc229f40cb fix new article layout 2023-11-25 11:07:31 +07:00
reya 54ad1e6e1d fix new post layout 2023-11-25 09:22:15 +07:00
Ren AmamiyaandGitHub 065ccbbea4 Merge pull request #121 from luminous-devs/fix/nsecbunker
Fix stuck issue for connect with nsecbunker
2023-11-24 13:53:26 +07:00
reya 74738c36cd disable blockUntilReady 2023-11-23 15:12:46 +07:00
Ren AmamiyaandGitHub 2fdf437789 Merge pull request #120 from luminous-devs/fix/logout
Fix logout function and other issues
2023-11-23 08:54:24 +07:00
reya 731c72535c bump version 2023-11-23 08:52:47 +07:00
reya 628102087e fix total account count function 2023-11-23 08:52:04 +07:00
reya 536ea30ed2 fix logout function 2023-11-23 08:49:05 +07:00
reya 8ee38cdb42 temp disable single-instance plugin 2023-11-22 17:27:09 +07:00
Ren AmamiyaandGitHub a896300f23 Merge pull request #118 from luminous-devs/v2.1.2
v2.1.2
2023-11-22 16:18:02 +07:00
reya d3cf1200ba bump version 2023-11-22 16:13:06 +07:00
reya b5ac3df090 fix package 2023-11-22 16:10:20 +07:00
reya 3b40dd6903 update dependencies 2023-11-22 15:27:19 +07:00
Ren AmamiyaandGitHub efba6b20ea Merge pull request #117 from luminous-devs/feat/optional-updater
Make auto update is optional
2023-11-22 10:34:49 +07:00
Ren AmamiyaandGitHub 05fb56e5fc Merge pull request #116 from vivganes/patch-1
Little grammar corrections
2023-11-22 10:32:47 +07:00
Vivek GanesanandGitHub 59d9646e9f Little grammar corrections 2023-11-22 08:43:31 +05:30
reya b73d84fccb update storage provider 2023-11-22 09:05:10 +07:00
reya 1929ceb72d add toast message 2023-11-22 08:31:58 +07:00
reya a1d22c1daf make auto update is optional 2023-11-22 08:30:43 +07:00
reya cf7af1ba64 fix ci again again 2023-11-20 08:47:16 +07:00
reya 933ca758ee fix ci again 2023-11-20 08:27:37 +07:00
reya f537209b92 fix ci 2023-11-20 08:16:39 +07:00
reya 6777610b07 update dependencies 2023-11-20 08:02:09 +07:00
reya 88803cd3cd bump version 2023-11-19 19:51:00 +07:00
Ren AmamiyaandGitHub 6adf5933b0 Merge pull request #113 from luminous-devs/hotfix/themes
Add support dark mode for toaster
2023-11-19 19:49:59 +07:00
reya 9521a49fff support dark mode for toaster 2023-11-19 19:49:26 +07:00
Ren AmamiyaandGitHub 5789a105f5 Merge pull request #112 from luminous-devs/hotfix/settings
Fix settings screen
2023-11-19 15:15:11 +07:00
reya b7a18bea34 respect user settings 2023-11-19 14:50:59 +07:00
reya 7117ed05a9 update settings screen 2023-11-19 08:48:01 +07:00
reya c53bdb68e5 add change theme function 2023-11-18 21:17:37 +07:00
reya 6725dca807 fix all dms bugs 2023-11-17 16:03:12 +07:00
Ren AmamiyaandGitHub 077712cf43 Merge pull request #105 from luminous-devs/feat/v2.1.0
v2.1.0
2023-11-17 10:06:58 +07:00
reya 2794c78ee1 add new private key screen 2023-11-17 09:24:21 +07:00
reya 21574023db update dark mode 2023-11-17 08:16:25 +07:00
reya 954b729dc9 wip: settings screen 2023-11-16 17:48:29 +07:00
reya 6dc4e1cde6 bump version 2023-11-16 08:38:23 +07:00
reya efbdf26706 add window state plugin 2023-11-16 08:25:08 +07:00
reya b41ec353c6 improve relay connection 2023-11-16 07:59:29 +07:00
reya 875225591a wip: settings screen 2023-11-15 14:20:45 +07:00
reya 04c1223f2e update article screen 2023-11-15 13:32:23 +07:00
reya 773e49afa2 wip 2023-11-15 13:13:11 +07:00
reya 025d7a623b polish 2023-11-15 10:27:47 +07:00
reya b6caab15e1 update tauri controls 2023-11-15 09:26:59 +07:00
reya 1d3d0a17dc fix updater 2023-11-15 08:32:40 +07:00
reya 1cbe781698 update error screen 2023-11-14 16:14:40 +07:00
reya dc5b4f8ac1 refactor publish event 2023-11-14 15:15:13 +07:00
reya fee4ad7b98 smal fixes and update article layout 2023-11-13 15:44:58 +07:00
reya d5647d7452 redesign loading screen 2023-11-13 08:16:36 +07:00
reya 0a5076f06c polish 2023-11-12 15:43:14 +07:00
reya a3632571ff polish 2023-11-12 08:41:47 +07:00
reya 5c48ebe103 improve spacing 2023-11-11 16:12:50 +07:00
reya 1c3119577f update widget list 2023-11-11 09:14:23 +07:00
reya 0710996a0d wip: update widget list 2023-11-10 16:05:20 +07:00
reya 0cdf199cb5 wip: rework widget 2023-11-09 18:02:25 +07:00
reya cb9006abb2 add topic widget 2023-11-09 09:12:42 +07:00
reya 108ecafab7 update widgets 2023-11-08 16:17:47 +07:00
reya 6b030f2902 polish 2023-11-08 08:21:52 +07:00
reya ce864c8990 wip 2023-11-07 16:23:01 +07:00
reya ee3e8eb105 wip 2023-11-07 09:35:13 +07:00
reya 701712e7b8 polish 2023-11-05 15:19:51 +07:00
reya dad388c6ab new parser 2023-11-04 14:18:29 +07:00
Ren AmamiyaandGitHub 912c740c51 Merge pull request #103 from luminous-devs/feat/personal-dashboard
Add personal dashboard
2023-11-03 15:41:28 +07:00
reya da0b48c5df update personal dashboard screen 2023-11-03 15:38:58 +07:00
reya 64b4745993 update personal screen 2023-11-03 08:45:25 +07:00
reya 8aa2ef39c5 update nip-05 and user profile component styles 2023-11-02 13:47:44 +07:00
reya a945f04959 update dependencies 2023-11-02 13:09:52 +07:00
reya c93989237a fix app crash issue 2023-11-01 16:59:13 +07:00
reya 95cf3f60f3 readd updater pubkey 2023-11-01 16:42:20 +07:00
Ren AmamiyaandGitHub dd21633624 Merge pull request #102 from luminous-devs/feat/v2.0.1
v2.0.1
2023-11-01 15:38:39 +07:00
reya f01074ea9f fix article 2023-11-01 15:37:50 +07:00
reya c8d04f4695 support tiff image 2023-11-01 15:24:46 +07:00
reya 2f8aa66ff6 bump version 2023-11-01 15:08:22 +07:00
reya 3ad6830bfb update title bar 2023-11-01 14:58:20 +07:00
reya f2dddf97f5 polish 2023-11-01 13:01:52 +07:00
reya e218ebee89 refactor text parser 2023-11-01 10:05:08 +07:00
reya fd5ecc18a9 refactor all widgets 2023-11-01 08:07:49 +07:00
reya e7738fb128 refactor newsfeed widget 2023-10-30 16:29:49 +07:00
reya 0b25a4a04b add ndk cache tauri 2023-10-29 11:07:05 +07:00
reya ace58ecdd5 refactor text parser 2023-10-28 14:36:12 +07:00
reya 6685d9af38 update user component 2023-10-28 08:29:38 +07:00
reya 60b803f419 unify upload function 2023-10-28 07:51:42 +07:00
reya 555c8ec08a migrate to tanstack query v5 2023-10-28 07:35:39 +07:00
reya 42eb882f52 update dependencies 2023-10-27 17:45:08 +07:00
219 changed files with 10730 additions and 10307 deletions
+3 -3
View File
@@ -51,8 +51,8 @@ jobs:
- uses: tauri-apps/tauri-action@dev - uses: tauri-apps/tauri-action@dev
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -62,7 +62,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
tagName: v__VERSION__ tagName: v__VERSION__
releaseName: 'App v__VERSION__' releaseName: 'v__VERSION__'
releaseBody: 'See the assets to download this version and install.' releaseBody: 'See the assets to download this version and install.'
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
-1
View File
@@ -11,7 +11,6 @@
"^@app/(.*)$", "^@app/(.*)$",
"^@libs/(.*)$", "^@libs/(.*)$",
"^@shared/(.*)$", "^@shared/(.*)$",
"^@stores/(.*)$",
"^@utils/(.*)$", "^@utils/(.*)$",
"^[./]" "^[./]"
], ],
+3 -1
View File
@@ -4,7 +4,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume</title> <title>Lume</title>
</head> </head>
<body class="relative cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen text-neutral-950 dark:text-neutral-50"> <body
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-neutral-950 antialiased dark:text-neutral-50"
>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
+69 -70
View File
@@ -2,7 +2,7 @@
"name": "lume", "name": "lume",
"description": "the communication app", "description": "the communication app",
"private": true, "private": true,
"version": "2.0.0", "version": "2.2.3",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@@ -18,11 +18,11 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write" "**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
}, },
"dependencies": { "dependencies": {
"@evilmartians/harmony": "^1.1.0", "@evilmartians/harmony": "^1.2.0",
"@getalby/sdk": "^2.5.0", "@getalby/sdk": "^2.7.0",
"@nostr-dev-kit/ndk": "^2.0.3", "@nostr-dev-kit/ndk": "^2.2.0",
"@nostr-dev-kit/ndk-cache-dexie": "^2.0.3", "@nostr-fetch/adapter-ndk": "^0.13.1",
"@nostr-fetch/adapter-ndk": "^0.13.0", "@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",
@@ -30,97 +30,96 @@
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toolbar": "^1.0.4", "@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "4.36.1", "@tanstack/react-query": "^5.12.2",
"@tauri-apps/api": "2.0.0-alpha.8", "@tanstack/react-query-devtools": "^5.12.2",
"@tauri-apps/cli": "2.0.0-alpha.15", "@tauri-apps/api": "2.0.0-alpha.11",
"@tauri-apps/plugin-app": "2.0.0-alpha.1", "@tauri-apps/cli": "2.0.0-alpha.17",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.1", "@tauri-apps/plugin-autostart": "2.0.0-alpha.3",
"@tauri-apps/plugin-dialog": "2.0.0-alpha.1", "@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
"@tauri-apps/plugin-fs": "2.0.0-alpha.1", "@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
"@tauri-apps/plugin-http": "2.0.0-alpha.1", "@tauri-apps/plugin-fs": "2.0.0-alpha.3",
"@tauri-apps/plugin-notification": "2.0.0-alpha.1", "@tauri-apps/plugin-http": "2.0.0-alpha.3",
"@tauri-apps/plugin-os": "2.0.0-alpha.2", "@tauri-apps/plugin-notification": "2.0.0-alpha.3",
"@tauri-apps/plugin-process": "2.0.0-alpha.1", "@tauri-apps/plugin-os": "2.0.0-alpha.4",
"@tauri-apps/plugin-shell": "2.0.0-alpha.1", "@tauri-apps/plugin-process": "2.0.0-alpha.3",
"@tauri-apps/plugin-sql": "2.0.0-alpha.1", "@tauri-apps/plugin-shell": "2.0.0-alpha.3",
"@tauri-apps/plugin-updater": "2.0.0-alpha.1", "@tauri-apps/plugin-sql": "2.0.0-alpha.3",
"@tauri-apps/plugin-upload": "2.0.0-alpha.1", "@tauri-apps/plugin-updater": "2.0.0-alpha.3",
"@tauri-apps/plugin-window": "2.0.0-alpha.1", "@tauri-apps/plugin-upload": "2.0.0-alpha.3",
"@tiptap/extension-character-count": "^2.1.12", "@tiptap/extension-character-count": "^2.1.13",
"@tiptap/extension-document": "^2.1.12", "@tiptap/extension-document": "^2.1.13",
"@tiptap/extension-image": "^2.1.12", "@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-mention": "^2.1.12", "@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-paragraph": "^2.1.12", "@tiptap/extension-paragraph": "^2.1.13",
"@tiptap/extension-placeholder": "^2.1.12", "@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/extension-text": "^2.1.12", "@tiptap/extension-text": "^2.1.13",
"@tiptap/pm": "^2.1.12", "@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.12", "@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.12", "@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.1.12", "@tiptap/suggestion": "^2.1.13",
"@vidstack/react": "^1.8.3",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"destr": "^2.0.1", "framer-motion": "^10.16.12",
"framer-motion": "^10.16.4",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"light-bolt11-decoder": "^3.0.0", "light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1", "lru-cache": "^10.1.0",
"media-chrome": "^1.4.4", "markdown-to-jsx": "^7.3.2",
"million": "^2.6.4",
"minidenticons": "^4.2.0", "minidenticons": "^4.2.0",
"nostr-fetch": "^0.13.0", "nanoid": "^5.0.4",
"nostr-fetch": "^0.13.1",
"nostr-tools": "^1.17.0", "nostr-tools": "^1.17.0",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"re-resizable": "^6.9.11", "re-resizable": "^6.9.11",
"react": "^18.2.0", "react": "^18.2.0",
"react-currency-input-field": "^3.6.11", "react-currency-input-field": "^3.6.12",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.48.2",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7", "react-router-dom": "^6.20.1",
"react-router-dom": "^6.17.0",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"reactflow": "^11.9.4", "reactflow": "^11.10.1",
"remark-gfm": "^3.0.1", "sonner": "^1.2.4",
"sonner": "^1.0.3", "tauri-controls": "github:reyamir/tauri-controls",
"tailwind-scrollbar": "^3.0.5",
"tauri-controls": "^0.2.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2", "tiptap-markdown": "^0.8.8",
"virtua": "^0.15.0", "virtua": "^0.17.4",
"zustand": "^4.4.3" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@trivago/prettier-plugin-sort-imports": "^4.2.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/html-to-text": "^9.0.3", "@types/html-to-text": "^9.0.4",
"@types/node": "^20.8.7", "@types/node": "^20.10.3",
"@types/react": "^18.2.29", "@types/react": "^18.2.41",
"@types/react-dom": "^18.2.14", "@types/react-dom": "^18.2.17",
"@types/youtube-player": "^5.5.9", "@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.13.1",
"@typescript-eslint/parser": "^6.8.0", "@vitejs/plugin-react-swc": "^3.5.0",
"@vitejs/plugin-react-swc": "^3.4.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13", "encoding": "^0.1.13",
"eslint": "^8.51.0", "eslint": "^8.55.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^14.0.1", "lint-staged": "^15.2.0",
"postcss": "^8.4.31", "postcss": "^8.4.32",
"prettier": "^3.0.3", "prettier": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.6", "prettier-plugin-tailwindcss": "^0.5.7",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3", "tailwind-scrollbar": "^3.0.5",
"typescript": "^5.2.2", "tailwindcss": "^3.3.5",
"typescript": "^5.3.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.1" "vite-tsconfig-paths": "^4.2.1"
} }
} }
+1671 -2261
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+887 -670
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lume" name = "lume"
version = "2.0.0" version = "2.2.0"
description = "the communication app" description = "the communication app"
authors = ["Ren Amamiya"] authors = ["Ren Amamiya"]
license = "GPL-3.0" license = "GPL-3.0"
@@ -18,7 +18,6 @@ tauri = { version = "2.0.0-alpha", features = [
"macos-private-api", "macos-private-api",
"native-tls-vendored", "native-tls-vendored",
] } ] }
tauri-plugin-app = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-cli = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-cli = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
@@ -29,12 +28,12 @@ tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", bra
tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-window = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v2", features = [ tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-theme = { git = "https://github.com/wyhaya/tauri-plugin-theme" }
tauri-plugin-sql = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2", features = [
"sqlite", "sqlite",
] } ] }
sqlx-cli = { version = "0.7.0", default-features = false, features = [ sqlx-cli = { version = "0.7.0", default-features = false, features = [
@@ -0,0 +1,27 @@
-- Add migration script here
CREATE TABLE
ndk_users (
pubkey TEXT NOT NULL PRIMARY KEY,
profile TEXT,
createdAt NUMBER
);
CREATE TABLE
ndk_events (
id TEXT NOT NULL PRIMARY KEY,
pubkey TEXT,
content TEXT,
kind NUMBER,
createdAt NUMBER,
relay TEXT,
event TEXT
);
CREATE TABLE
ndk_eventtags (
id TEXT NOT NULL PRIMARY KEY,
eventId TEXT,
tag TEXT,
value TEXT,
tagValue TEXT
);
@@ -0,0 +1,5 @@
ALTER TABLE accounts DROP COLUMN follows;
ALTER TABLE accounts DROP COLUMN circles;
ALTER TABLE accounts DROP COLUMN last_login_at;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS relays;
+33 -27
View File
@@ -5,9 +5,9 @@
use keyring::Entry; use keyring::Entry;
use std::time::Duration; use std::time::Duration;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
use tauri_plugin_theme::ThemePlugin;
use webpage::{Webpage, WebpageOptions}; use webpage::{Webpage, WebpageOptions};
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
@@ -106,6 +106,7 @@ fn secure_remove(key: String) -> Result<(), ()> {
} }
fn main() { fn main() {
let mut ctx = tauri::generate_context!();
tauri::Builder::default() tauri::Builder::default()
.setup(|app| { .setup(|app| {
#[cfg(desktop)] #[cfg(desktop)]
@@ -114,7 +115,34 @@ fn main() {
.plugin(tauri_plugin_updater::Builder::new().build())?; .plugin(tauri_plugin_updater::Builder::new().build())?;
Ok(()) Ok(())
}) })
.plugin(tauri_plugin_app::init()) .plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
"sqlite:lume_v2.db",
vec![
Migration {
version: 20230418013219,
description: "initial data",
sql: include_str!("../migrations/20230418013219_initial_data.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20231028083224,
description: "add ndk cache table",
sql: include_str!("../migrations/20231028083224_add_ndk_cache_table.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20231130105202,
description: "clean up table",
sql: include_str!("../migrations/20231130105202_clean_up_table.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
)
.plugin(ThemePlugin::init(ctx.config_mut()))
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
@@ -124,39 +152,17 @@ fn main() {
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_upload::init()) .plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_window::init())
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
"sqlite:lume_v2.db",
vec![Migration {
version: 20230418013219,
description: "initial data",
sql: include_str!("../migrations/20230418013219_initial_data.sql"),
kind: MigrationKind::Up,
}],
)
.build(),
)
.plugin(tauri_plugin_autostart::init( .plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent, MacosLauncher::LaunchAgent,
Some(vec!["--flag1", "--flag2"]), Some(vec![]),
)) ))
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
println!("{}, {argv:?}, {cwd}", app.package_info().name);
app
.emit_all("single-instance", Payload { args: argv, cwd })
.unwrap();
}))
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_store::Builder::default().build())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
opengraph, opengraph,
secure_save, secure_save,
secure_load, secure_load,
secure_remove secure_remove
]) ])
.run(tauri::generate_context!()) .run(ctx)
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+16 -10
View File
@@ -9,7 +9,7 @@
}, },
"package": { "package": {
"productName": "Lume", "productName": "Lume",
"version": "2.0.0" "version": "2.2.3"
}, },
"plugins": { "plugins": {
"fs": { "fs": {
@@ -45,15 +45,12 @@
"tauri": { "tauri": {
"bundle": { "bundle": {
"active": true, "active": true,
"appimage": {
"bundleMediaFramework": true
},
"category": "SocialNetworking", "category": "SocialNetworking",
"copyright": "",
"deb": { "deb": {
"depends": [] "depends": []
}, },
"externalBin": [], "externalBin": [],
"resources": [],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -61,8 +58,21 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
], ],
"copyright": "",
"identifier": "com.lume.nu", "identifier": "com.lume.nu",
"longDescription": "", "longDescription": "The communication app build on Nostr Protocol",
"shortDescription": "",
"targets": "all",
"updater": {
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3OTdCMkM3RjU5QzE2NzkKUldSNUZwejF4N0tYNTVHYjMrU0JkL090SlEyNUVLYU5TM2hTU3RXSWtEWngrZWJ4a0pydUhXZHEK",
"windows": {
"installMode": "quiet"
}
},
"appimage": {
"bundleMediaFramework": true
},
"macOS": { "macOS": {
"entitlements": null, "entitlements": null,
"exceptionDomain": "", "exceptionDomain": "",
@@ -72,10 +82,6 @@
"providerShortName": null, "providerShortName": null,
"signingIdentity": null "signingIdentity": null
}, },
"resources": [],
"shortDescription": "",
"targets": "all",
"updater": {},
"windows": { "windows": {
"certificateThumbprint": null, "certificateThumbprint": null,
"digestAlgorithm": "sha256", "digestAlgorithm": "sha256",
+10 -22
View File
@@ -1,4 +1,8 @@
@import 'reactflow/dist/style.css'; /* @import 'reactflow/dist/style.css'; */
/* Vidstack */
@import '@vidstack/react/player/styles/default/theme.css';
@import '@vidstack/react/player/styles/default/layouts/video.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -10,6 +14,10 @@
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.prose :where(iframe):not(:where([class~="not-prose"] *)) {
@apply aspect-video w-full h-auto mx-auto;
}
} }
html { html {
@@ -37,28 +45,8 @@ input::-ms-clear {
background-clip: padding-box; background-clip: padding-box;
} }
.player {
--brand-color: #f5f5f5;
--focus-color: #4e9cf6;
--audio-brand: var(--brand-color);
--audio-focus-ring-color: var(--focus-color);
--audio-border-radius: 2px;
--video-brand: var(--brand-color);
--video-focus-ring-color: var(--focus-color);
--video-border-radius: 8px;
width: 100%;
}
.player[data-view-type='video'] {
aspect-ratio: 16 /9;
}
.ProseMirror p.is-empty::before { .ProseMirror p.is-empty::before {
@apply text-neutral-600 dark:text-neutral-400; @apply text-neutral-600 dark:text-neutral-400 float-left h-0 pointer-events-none content-[attr(data-placeholder)];
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
} }
.ProseMirror img.ProseMirror-selectednode { .ProseMirror img.ProseMirror-selectednode {
+98 -41
View File
@@ -1,18 +1,18 @@
import { message } from '@tauri-apps/plugin-dialog'; import { message } from '@tauri-apps/plugin-dialog';
import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom'; import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow'; import { ReactFlowProvider } from 'reactflow';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats'; import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error'; import { ErrorScreen } from '@app/error';
import { ExploreScreen } from '@app/explore'; import { ExploreScreen } from '@app/explore';
import { NewScreen } from '@app/new';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app'; import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth'; import { AuthLayout } from '@shared/layouts/auth';
import { NewLayout } from '@shared/layouts/new';
import { NoteLayout } from '@shared/layouts/note'; import { NoteLayout } from '@shared/layouts/note';
import { SettingsLayout } from '@shared/layouts/settings'; import { SettingsLayout } from '@shared/layouts/settings';
@@ -54,8 +54,8 @@ export default function App() {
{ {
path: '', path: '',
async lazy() { async lazy() {
const { SpaceScreen } = await import('@app/space'); const { HomeScreen } = await import('@app/home');
return { Component: SpaceScreen }; return { Component: HomeScreen };
}, },
}, },
{ {
@@ -114,7 +114,7 @@ export default function App() {
}, },
{ {
path: '/new', path: '/new',
element: <NewScreen />, element: <NewLayout />,
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
children: [ children: [
{ {
@@ -138,6 +138,13 @@ export default function App() {
return { Component: NewFileScreen }; return { Component: NewFileScreen };
}, },
}, },
{
path: 'privkey',
async lazy() {
const { NewPrivkeyScreen } = await import('@app/new/privkey');
return { Component: NewPrivkeyScreen };
},
},
], ],
}, },
{ {
@@ -189,37 +196,52 @@ export default function App() {
}, },
{ {
path: 'onboarding', path: 'onboarding',
element: <OnboardingScreen />, async lazy() {
errorElement: <ErrorScreen />, const { OnboardingScreen } = await import('@app/auth/onboarding');
children: [ return { Component: OnboardingScreen };
{ },
path: '', },
async lazy() { {
const { OnboardingListScreen } = await import( path: 'follow',
'@app/auth/onboarding/list' async lazy() {
); const { FollowScreen } = await import('@app/auth/follow');
return { Component: OnboardingListScreen }; return { Component: FollowScreen };
}, },
}, },
{ {
path: 'enrich', path: 'finish',
async lazy() { async lazy() {
const { OnboardEnrichScreen } = await import( const { FinishScreen } = await import('@app/auth/finish');
'@app/auth/onboarding/enrich' return { Component: FinishScreen };
); },
return { Component: OnboardEnrichScreen }; },
}, {
}, path: 'tutorials/note',
{ async lazy() {
path: 'hashtag', const { TutorialNoteScreen } = await import('@app/auth/tutorials/note');
async lazy() { return { Component: TutorialNoteScreen };
const { OnboardHashtagScreen } = await import( },
'@app/auth/onboarding/hashtag' },
); {
return { Component: OnboardHashtagScreen }; path: 'tutorials/widget',
}, async lazy() {
}, const { TutorialWidgetScreen } = await import('@app/auth/tutorials/widget');
], return { Component: TutorialWidgetScreen };
},
},
{
path: 'tutorials/posting',
async lazy() {
const { TutorialPostingScreen } = await import('@app/auth/tutorials/posting');
return { Component: TutorialPostingScreen };
},
},
{
path: 'tutorials/finish',
async lazy() {
const { TutorialFinishScreen } = await import('@app/auth/tutorials/finish');
return { Component: TutorialFinishScreen };
},
}, },
], ],
}, },
@@ -231,15 +253,50 @@ export default function App() {
{ {
path: '', path: '',
async lazy() { async lazy() {
const { GeneralSettingsScreen } = await import('@app/settings/general'); const { UserSettingScreen } = await import('@app/settings');
return { Component: GeneralSettingsScreen }; return { Component: UserSettingScreen };
},
},
{
path: 'edit-profile',
async lazy() {
const { EditProfileScreen } = await import('@app/settings/editProfile');
return { Component: EditProfileScreen };
},
},
{
path: 'edit-contact',
async lazy() {
const { EditContactScreen } = await import('@app/settings/editContact');
return { Component: EditContactScreen };
},
},
{
path: 'general',
async lazy() {
const { GeneralSettingScreen } = await import('@app/settings/general');
return { Component: GeneralSettingScreen };
}, },
}, },
{ {
path: 'backup', path: 'backup',
async lazy() { async lazy() {
const { AccountSettingsScreen } = await import('@app/settings/account'); const { BackupSettingScreen } = await import('@app/settings/backup');
return { Component: AccountSettingsScreen }; return { Component: BackupSettingScreen };
},
},
{
path: 'advanced',
async lazy() {
const { AdvancedSettingScreen } = await import('@app/settings/advanced');
return { Component: AdvancedSettingScreen };
},
},
{
path: 'about',
async lazy() {
const { AboutScreen } = await import('@app/settings/about');
return { Component: AboutScreen };
}, },
}, },
], ],
@@ -251,7 +308,7 @@ export default function App() {
router={router} router={router}
fallbackElement={ fallbackElement={
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin text-white" /> <LoaderIcon className="h-6 w-6 animate-spin" />
</div> </div>
} }
future={{ v7_startTransition: true }} future={{ v7_startTransition: true }}
@@ -1,50 +0,0 @@
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function AllowNotification() {
const [notification, setNotification] = useOnboarding((state) => [
state.notification,
state.toggleNotification,
]);
const allow = async () => {
let permissionGranted = await isPermissionGranted();
if (!permissionGranted) {
const permission = await requestPermission();
permissionGranted = permission === 'granted';
}
if (permissionGranted) {
setNotification();
}
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Allow notification</h5>
<p className="text-sm">
By allowing Lume to send notifications in your OS settings, you will receive
notification messages when someone interacts with you or your content.
</p>
</div>
{notification ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={allow}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Allow
</button>
)}
</div>
</div>
);
}
@@ -1,100 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { LRUCache } from 'lru-cache';
import { useState } from 'react';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function Circle() {
const { db } = useStorage();
const { ndk } = useNDK();
const [circle, setCircle] = useOnboarding((state) => [
state.circle,
state.toggleCircle,
]);
const [loading, setLoading] = useState(false);
const enableLinks = async () => {
setLoading(true);
const users = ndk.getUser({ hexpubkey: db.account.pubkey });
const follows = await users.follows();
if (follows.size === 0) {
setLoading(false);
return toast('You need to follow at least 1 account');
}
const lru = new LRUCache<string, string, void>({ max: 300 });
const followsAsArr = [];
// add user's follows to lru
follows.forEach((user) => {
lru.set(user.pubkey, user.pubkey);
followsAsArr.push(user.pubkey);
});
// get follows from follows
const events = await ndk.fetchEvents({
kinds: [NDKKind.Contacts],
authors: followsAsArr,
limit: 300,
});
events.forEach((event: NDKEvent) => {
event.tags.forEach((tag) => {
if (tag[0] === 'p') lru.set(tag[1], tag[1]);
});
});
// get lru values
const circleList = [...lru.values()] as string[];
// update db
await db.updateAccount('follows', JSON.stringify(followsAsArr));
await db.updateAccount('circles', JSON.stringify(circleList));
db.account.follows = followsAsArr;
db.account.circles = circleList;
// clear lru
lru.clear();
// done
await db.createSetting('circles', '1');
setCircle();
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Enable Circle</h5>
<p className="text-sm">
Beside newsfeed from your follows, you will see more content from all people
that followed by your follows.
</p>
</div>
{circle ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={enableLinks}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Enable'}
</button>
)}
</div>
</div>
);
}
@@ -1,47 +0,0 @@
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function OutboxModel() {
const { db } = useStorage();
const [outbox, setOutbox] = useOnboarding((state) => [
state.outbox,
state.toggleOutbox,
]);
const enableOutbox = async () => {
await db.createSetting('outbox', '1');
setOutbox();
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Enable Outbox (experiment)</h5>
<p className="text-sm">
When you request information about a user, Lume will automatically query the
user&apos;s outbox relays and subsequent queries will favour using those
relays for queries with that user&apos;s pubkey.
</p>
</div>
{outbox ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={enableOutbox}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Enable
</button>
)}
</div>
</div>
);
}
@@ -1,35 +0,0 @@
import { Link } from 'react-router-dom';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function FavoriteHashtag() {
const hashtag = useOnboarding((state) => state.hashtag);
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between">
<div>
<h5 className="font-semibold">Favorite hashtag</h5>
<p className="text-sm">
By adding favorite hashtag, Lume will display all contents related to this
hashtag as a column
</p>
</div>
{hashtag ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<Link
to="/auth/onboarding/hashtag"
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Add
</Link>
)}
</div>
</div>
);
}
@@ -1,58 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function FollowList() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery(
['follows'],
async () => {
const user = ndk.getUser({ hexpubkey: db.account.pubkey });
const follows = await user.follows();
const followsAsArr = [];
follows.forEach((user) => {
followsAsArr.push(user.pubkey);
});
// update db
await db.updateAccount('follows', JSON.stringify(followsAsArr));
await db.updateAccount('circles', JSON.stringify(followsAsArr));
db.account.follows = followsAsArr;
db.account.circles = followsAsArr;
return followsAsArr;
},
{
refetchOnWindowFocus: false,
}
);
return (
<div className="relative rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<h5 className="font-semibold">Your follows</h5>
<div className="mt-2 flex w-full items-center justify-center">
{status === 'loading' ? (
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
) : (
<div className="isolate flex -space-x-2">
{data.slice(0, 16).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{data.length > 16 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-200 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-800">
<span className="text-xs font-medium">+{data.length}</span>
</div>
) : null}
</div>
)}
</div>
</div>
);
}
@@ -1,35 +0,0 @@
import { Link } from 'react-router-dom';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function SuggestFollow() {
const enrich = useOnboarding((state) => state.enrich);
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between">
<div>
<h5 className="font-semibold">Enrich your network</h5>
<p className="text-sm">
Follow more people to stay up to date with everything happening around the
world.
</p>
</div>
{enrich ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<Link
to="/auth/onboarding/enrich"
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Check
</Link>
)}
</div>
</div>
);
}
+53 -37
View File
@@ -1,7 +1,7 @@
import { NDKEvent, NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { downloadDir } from '@tauri-apps/api/path'; import { downloadDir } from '@tauri-apps/api/path';
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { message, save } from '@tauri-apps/plugin-dialog'; import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs'; import { writeTextFile } from '@tauri-apps/plugin-fs';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { minidenticon } from 'minidenticons'; import { minidenticon } from 'minidenticons';
@@ -15,7 +15,7 @@ import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader'; import { AvatarUploader } from '@shared/avatarUploader';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons'; import { ArrowLeftIcon, InfoIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function CreateAccountScreen() { export function CreateAccountScreen() {
@@ -67,7 +67,6 @@ export function CreateAccountScreen() {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile); event.content = JSON.stringify(profile);
event.kind = NDKKind.Metadata; event.kind = NDKKind.Metadata;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = userPubkey; event.pubkey = userPubkey;
event.tags = []; event.tags = [];
@@ -76,6 +75,16 @@ export function CreateAccountScreen() {
if (publish) { if (publish) {
await db.createAccount(userNpub, userPubkey); await db.createAccount(userNpub, userPubkey);
await db.secureSave(userPubkey, userPrivkey); await db.secureSave(userPubkey, userPrivkey);
const relayListEvent = new NDKEvent(ndk);
relayListEvent.kind = NDKKind.RelayList;
relayListEvent.tags = [...ndk.pool.relays.values()].map((item) => [
'r',
item.url,
]);
await relayListEvent.publish();
setKeys({ setKeys({
npub: userNpub, npub: userNpub,
nsec: userNsec, nsec: userNsec,
@@ -88,7 +97,7 @@ export function CreateAccountScreen() {
setLoading(false); setLoading(false);
} }
} catch (e) { } catch (e) {
return toast(e); return toast.error(e);
} }
}; };
@@ -113,7 +122,7 @@ export function CreateAccountScreen() {
setDownloaded(true); setDownloaded(true);
} // else { user cancel action } } // else { user cancel action }
} catch (e) { } catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' }); return toast.error(e);
} }
}; };
@@ -123,33 +132,33 @@ export function CreateAccountScreen() {
{!keys ? ( {!keys ? (
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium" className="group inline-flex items-center gap-2 text-sm font-medium"
> >
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200"> <div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 group-hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-200 dark:group-hover:bg-neutral-700">
<ArrowLeftIcon className="h-5 w-5" /> <ArrowLeftIcon className="h-4 w-4" />
</div> </div>
Back Back
</button> </button>
) : null} ) : null}
</div> </div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10"> <div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100"> <h1 className="text-center text-2xl font-semibold">
Let&apos;s set up your account. Let&apos;s set up your account.
</h1> </h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{!keys ? ( {!keys ? (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"> <div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} /> <input type={'hidden'} {...register('picture')} value={picture} />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-semibold">Avatar</span> <span className="font-semibold">Avatar</span>
<div className="relative flex h-36 w-full items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800"> <div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-900">
{picture.length > 0 ? ( {picture.length > 0 ? (
<img <img
src={picture} src={picture}
alt="user's avatar" alt="user's avatar"
className="h-14 w-14 rounded-xl" className="h-14 w-14 rounded-xl object-cover"
/> />
) : ( ) : (
<img <img
@@ -158,9 +167,7 @@ export function CreateAccountScreen() {
className="h-14 w-14 rounded-xl bg-black dark:bg-white" className="h-14 w-14 rounded-xl bg-black dark:bg-white"
/> />
)} )}
<div className="absolute bottom-2 right-2"> <AvatarUploader setPicture={setPicture} />
<AvatarUploader setPicture={setPicture} />
</div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -174,7 +181,7 @@ export function CreateAccountScreen() {
minLength: 1, minLength: 1,
})} })}
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -184,20 +191,29 @@ export function CreateAccountScreen() {
<textarea <textarea
{...register('about')} {...register('about')}
spellCheck={false} spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-neutral-200 px-3 py-2 !outline-none placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<button <div className="flex flex-col gap-3">
type="submit" <div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
disabled={!isDirty || !isValid} <InfoIcon className="h-8 w-8" />
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50" <p>
> There are many more settings you can configure from the
{loading ? ( &quot;Settings&quot; screen. Be sure to visit it later.
<LoaderIcon className="h-5 w-4 animate-spin" /> </p>
) : ( </div>
'Create and Continue' <button
)} type="submit"
</button> disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Create and Continue'
)}
</button>
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -209,7 +225,7 @@ export function CreateAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200" className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
> >
<User pubkey={keys.pubkey} variant="simple" /> <User pubkey={keys.pubkey} variant="simple" />
</motion.div> </motion.div>
@@ -219,7 +235,7 @@ export function CreateAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200" className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
> >
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<h5 className="font-semibold">Backup account</h5> <h5 className="font-semibold">Backup account</h5>
@@ -227,7 +243,7 @@ export function CreateAccountScreen() {
<p className="mb-2 select-text text-sm text-neutral-800 dark:text-neutral-200"> <p className="mb-2 select-text text-sm text-neutral-800 dark:text-neutral-200">
Your private key is your password. If you lose this key, you will Your private key is your password. If you lose this key, you will
lose access to your account! Copy it and keep it in a safe place.{' '} lose access to your account! Copy it and keep it in a safe place.{' '}
<span className="text-red-600"> <span className="text-red-500">
There is no way to reset your private key. There is no way to reset your private key.
</span> </span>
</p> </p>
@@ -247,13 +263,13 @@ export function CreateAccountScreen() {
value={ value={
keys.nsec.substring(0, 10) + '**************************' keys.nsec.substring(0, 10) + '**************************'
} }
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2"> <div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
<button <button
type="button" type="button"
onClick={copyNsec} onClick={copyNsec}
className="rounded-md bg-neutral-300 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600" className="rounded-md bg-neutral-200 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
> >
Copy Copy
</button> </button>
@@ -267,7 +283,7 @@ export function CreateAccountScreen() {
<input <input
readOnly readOnly
value={keys.npub} value={keys.npub}
className="h-11 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
</div> </div>
@@ -275,7 +291,7 @@ export function CreateAccountScreen() {
<button <button
type="button" type="button"
onClick={() => download()} onClick={() => download()}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="mt-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
> >
Download account keys Download account keys
</button> </button>
@@ -291,9 +307,9 @@ export function CreateAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
type="button" type="button"
onClick={() => navigate('/auth/onboarding', { state: { newuser: true } })} onClick={() => navigate('/auth/onboarding')}
> >
Finish Finish
</motion.button> </motion.button>
+34
View File
@@ -0,0 +1,34 @@
import { Link } from 'react-router-dom';
export function FinishScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl font-light">
Yo, you&apos;re ready to use <span className="font-bold">Lume</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/tutorials/note"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Start tutorial
</Link>
<Link
to="/"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Skip
</Link>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
You need to restart app to make changes in previous step take effect or you
can continue with Lume default settings
</p>
</div>
</div>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import * as Accordion from '@radix-ui/react-accordion';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import {
ArrowLeftIcon,
ArrowRightIcon,
CancelIcon,
ChevronDownIcon,
LoaderIcon,
PlusIcon,
} from '@shared/icons';
import { User } from '@shared/user';
const POPULAR_USERS = [
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6',
'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
'npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s',
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8',
'npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a',
'npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc',
'npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza',
'npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424',
'npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac',
'npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv',
'npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk',
];
const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445'];
export function FollowScreen() {
const { ndk } = useNDK();
const { db } = useStorage();
const { status, data } = useQuery({
queryKey: ['trending-profiles-widget'],
queryFn: async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
},
});
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
const navigate = useNavigate();
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const submit = async () => {
try {
setLoading(true);
if (!follows.length) return navigate('/auth/finish');
const event = new NDKEvent(ndk);
event.kind = NDKKind.Contacts;
event.tags = follows.map((item) => {
if (item.startsWith('npub')) return ['p', nip19.decode(item).data as string];
return ['p', item];
});
const publish = await event.publish();
if (publish) {
db.account.contacts = follows.map((item) => {
if (item.startsWith('npub')) return nip19.decode(item).data as string;
return item;
});
return navigate('/auth/finish');
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-semibold">Dive into the nostrverse</h1>
<h2 className="text-neutral-700 dark:text-neutral-300">
Try following some users that interest you
<br />
to build up your timeline.
</h2>
</div>
<Accordion.Root type="single" defaultValue="recommended" collapsible>
<Accordion.Item value="recommended" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Popular users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{POPULAR_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="trending" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Trending users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data?.profiles.map(
(item: { pubkey: string; profile: { content: string } }) => (
<div
key={item.pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(item.pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(item.pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
)
)
)}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="lume" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Lume team
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{LUME_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</div>
<div className="absolute bottom-3 right-3 flex w-full items-center justify-end gap-2">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-3 font-semibold hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-blue-800"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
<button
type="button"
onClick={submit}
disabled={loading}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-blue-500 px-3 font-semibold text-white hover:bg-blue-600"
>
Continue
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<ArrowRightIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
);
}
+61 -29
View File
@@ -1,5 +1,6 @@
import { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { readText } from '@tauri-apps/plugin-clipboard-manager'; import { readText } from '@tauri-apps/plugin-clipboard-manager';
import { open } from '@tauri-apps/plugin-shell';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { useState } from 'react'; import { useState } from 'react';
@@ -10,21 +11,22 @@ import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon } from '@shared/icons'; import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function ImportAccountScreen() { export function ImportAccountScreen() {
const navigate = useNavigate();
const { db } = useStorage(); const { db } = useStorage();
const { ndk } = useNDK(); const { ndk } = useNDK();
const [npub, setNpub] = useState<string>(''); const [npub, setNpub] = useState<string>('');
const [nsec, setNsec] = useState<string>(''); const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(undefined); const [pubkey, setPubkey] = useState<undefined | string>(undefined);
const [loading, setLoading] = useState(false);
const [created, setCreated] = useState({ ok: false, remote: false }); const [created, setCreated] = useState({ ok: false, remote: false });
const [savedPrivkey, setSavedPrivkey] = useState(false); const [savedPrivkey, setSavedPrivkey] = useState(false);
const navigate = useNavigate();
const submitNpub = async () => { const submitNpub = async () => {
if (npub.length < 6) return toast.error('You must enter valid npub'); if (npub.length < 6) return toast.error('You must enter valid npub');
if (!npub.startsWith('npub1')) return toast.error('npub must be starts with npub1'); if (!npub.startsWith('npub1')) return toast.error('npub must be starts with npub1');
@@ -44,9 +46,19 @@ export function ImportAccountScreen() {
try { try {
const pubkey = nip19.decode(npub.split('#')[0]).data as string; const pubkey = nip19.decode(npub.split('#')[0]).data as string;
const localSigner = NDKPrivateKeySigner.generate(); const localSigner = NDKPrivateKeySigner.generate();
await db.secureSave(pubkey + '-bunker', localSigner.privateKey);
const remoteSigner = new NDKNip46Signer(ndk, npub, localSigner); await db.createSetting('nsecbunker', '1');
await db.secureSave(`${npub}-nsecbunker`, localSigner.privateKey);
// open nsecbunker web app in default browser
await open('https://app.nsecbunker.com/keys');
const bunker = new NDK({
explicitRelayUrls: ['wss://relay.nsecbunker.com', 'wss://nostr.vulpem.com'],
});
await bunker.connect();
const remoteSigner = new NDKNip46Signer(bunker, npub, localSigner);
await remoteSigner.blockUntilReady(); await remoteSigner.blockUntilReady();
ndk.signer = remoteSigner; ndk.signer = remoteSigner;
@@ -65,12 +77,25 @@ export function ImportAccountScreen() {
const createAccount = async () => { const createAccount = async () => {
try { try {
await db.createAccount(npub, pubkey); setLoading(true);
setCreated((prev) => ({ ...prev, ok: true }));
if (created.remote) navigate('/auth/onboarding', { state: { newuser: false } }); // add account to db
await db.createAccount(npub, pubkey);
// get account metadata
const user = ndk.getUser({ pubkey });
if (user) {
db.account.contacts = [...(await user.follows())].map((user) => user.pubkey);
db.account.relayList = await user.relayList();
}
setCreated((prev) => ({ ...prev, ok: true }));
setLoading(false);
if (created.remote) navigate('/auth/onboarding');
} catch (e) { } catch (e) {
return toast(`Create account failed: ${e}`); setLoading(false);
return toast.error(e);
} }
}; };
@@ -111,17 +136,16 @@ export function ImportAccountScreen() {
) : null} ) : null}
</div> </div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10"> <div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100"> <h1 className="text-center text-2xl font-semibold">Import your account.</h1>
Import your account.
</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"> <div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold"> <label htmlFor="npub" className="font-semibold">
Enter your public key: Enter your public key:
</label> </label>
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<input <input
readOnly={!!pubkey}
name="npub" name="npub"
type="text" type="text"
value={npub} value={npub}
@@ -131,26 +155,32 @@ export function ImportAccountScreen() {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="npub1" placeholder="npub1"
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
{!pubkey ? ( {!pubkey ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
type="button" type="button"
onClick={submitNpub} onClick={submitNpub}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="h-11 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
> >
Continue Continue
</button> </button>
<button <button
type="button" type="button"
onClick={connectNsecBunker} onClick={connectNsecBunker}
className="h-9 w-full shrink-0 rounded-lg bg-neutral-200 font-semibold text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" className="h-11 w-full shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
> >
Continue with nsecBunker Continue with nsecBunker
</button> </button>
</div> </div>
) : null} ) : null}
{npub.indexOf('#') > -1 ? (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
You&apos;re using nsecbunker token, keep in mind it only can redeem
one-time, you need to login again in the next launch
</p>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -161,16 +191,16 @@ export function ImportAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200" className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
> >
<h5 className="mb-1.5 font-semibold">Account found</h5> <h5 className="mb-1.5 font-semibold">Account found</h5>
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-200 p-2"> <div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-100 px-4 py-3 dark:bg-neutral-900">
<User pubkey={pubkey} variant="simple" /> <User pubkey={pubkey} variant="simple" />
<button <button
type="button" type="button"
onClick={changeAccount} onClick={changeAccount}
className="h-8 w-20 shrink-0 rounded-lg bg-neutral-300 text-sm font-medium text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600" className="h-8 w-max shrink-0 rounded-lg bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
> >
Change Change
</button> </button>
@@ -179,9 +209,13 @@ export function ImportAccountScreen() {
<button <button
type="button" type="button"
onClick={createAccount} onClick={createAccount}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
> >
Continue {loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Continue'
)}
</button> </button>
) : null} ) : null}
</div> </div>
@@ -214,7 +248,7 @@ export function ImportAccountScreen() {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="nsec1" placeholder="nsec1"
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" className="h-11 w-full rounded-lg border-transparent bg-neutral-200 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
{nsec.length < 5 ? ( {nsec.length < 5 ? (
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2"> <div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
@@ -259,8 +293,8 @@ export function ImportAccountScreen() {
{db.platform === 'macos' {db.platform === 'macos'
? 'Apple Keychain (macOS)' ? 'Apple Keychain (macOS)'
: db.platform === 'windows' : db.platform === 'windows'
? 'Credential Manager (Windows)' ? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'} : 'Secret Service (Linux)'}
</b> </b>
, it will be secured by your OS , it will be secured by your OS
</p> </p>
@@ -281,11 +315,9 @@ export function ImportAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
type="button" type="button"
onClick={() => onClick={() => navigate('/auth/onboarding')}
navigate('/auth/onboarding', { state: { newuser: false } })
}
> >
Continue Continue
</motion.button> </motion.button>
+148
View File
@@ -0,0 +1,148 @@
import * as Switch from '@radix-ui/react-switch';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { InfoIcon } from '@shared/icons';
export function OnboardingScreen() {
const { db } = useStorage();
const navigate = useNavigate();
const [settings, setSettings] = useState({
autoupdate: false,
outbox: false,
notification: false,
});
const next = () => {
if (!db.account.contacts.length) return navigate('/auth/follow');
return navigate('/auth/finish');
};
const toggleOutbox = async () => {
await db.createSetting('outbox', String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleAutoupdate = async () => {
await db.createSetting('autoupdate', String(+!settings.autoupdate));
db.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await db.getAllSettings();
if (!data) return;
data.forEach((item) => {
if (item.key === 'autoupdate')
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === 'outbox')
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
});
}
loadSettings();
}, []);
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-light text-neutral-900 dark:text-neutral-100">
You&apos;re almost ready to use Lume.
</h1>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Let&apos;s start personalizing your experience.
</h2>
</div>
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Auto check for update on Login</h3>
<p className="text-sm">
Keep Lume up to date with latest version, always have new features and bug
free.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Push notification</h3>
<p className="text-sm">
Enabling push notifications will allow you to receive notifications from
Lume directly on your device.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Use Gossip model (recommended)</h3>
<p className="text-sm">
Automatically discover relays to connect based on the preferences of each
author.
</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<InfoIcon className="h-8 w-8" />
<p>
There are many more settings you can configure from the &quot;Settings&quot;
screen. Be sure to visit it later.
</p>
</div>
<button
type="button"
onClick={next}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
</div>
</div>
</div>
);
}
-138
View File
@@ -1,138 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardEnrichScreen() {
const { ndk } = useNDK();
const { db } = useStorage();
const { status, data } = useQuery(['trending-profiles-widget'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
});
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const navigate = useNavigate();
const setEnrich = useOnboarding((state) => state.toggleEnrich);
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const submit = async () => {
try {
setLoading(true);
const tags = arrayToNIP02(follows);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.created_at = Math.floor(Date.now() / 1000);
event.tags = tags;
const publish = await event.publish();
// redirect to next step
if (publish) {
db.account.follows = follows;
await db.updateAccount('follows', JSON.stringify(follows));
await db.updateAccount('circles', JSON.stringify(follows));
setEnrich();
navigate(-1);
} else {
setLoading(false);
}
} catch (e) {
setLoading(false);
toast(e);
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
</div>
Back
</button>
</div>
<div className="mx-auto mb-8 w-full max-w-md px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Enrich your network
</h1>
</div>
<div className="flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4 scrollbar-none">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
) : (
data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="relative h-[300px] shrink-0 grow-0 basis-[250px] overflow-hidden rounded-lg bg-neutral-200 px-4 py-4 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
{follows.includes(item.pubkey) && (
<div className="absolute right-2 top-2">
<CheckCircleIcon className="h-5 w-5 text-teal-400" />
</div>
)}
</button>
))
)}
</div>
<div className="mx-auto mt-8 w-full max-w-md px-3">
<button
type="button"
onClick={submit}
disabled={loading || follows.length === 0}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>It might take a bit, please patient...</span>
</>
) : (
<span>Follow {follows.length} accounts & Continue</span>
)}
</button>
</div>
</div>
);
}
-131
View File
@@ -1,131 +0,0 @@
import { message } from '@tauri-apps/plugin-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
const data = [
{ hashtag: '#bitcoin' },
{ hashtag: '#nostr' },
{ hashtag: '#nostrdesign' },
{ hashtag: '#security' },
{ hashtag: '#zap' },
{ hashtag: '#LFG' },
{ hashtag: '#zapchain' },
{ hashtag: '#shitcoin' },
{ hashtag: '#plebchain' },
{ hashtag: '#nodes' },
{ hashtag: '#hodl' },
{ hashtag: '#stacksats' },
{ hashtag: '#nokyc' },
{ hashtag: '#meme' },
{ hashtag: '#memes' },
{ hashtag: '#memestr' },
{ hashtag: '#nostriches' },
{ hashtag: '#dev' },
{ hashtag: '#anime' },
{ hashtag: '#waifu' },
{ hashtag: '#manga' },
{ hashtag: '#lume' },
{ hashtag: '#snort' },
{ hashtag: '#damus' },
{ hashtag: '#primal' },
];
export function OnboardHashtagScreen() {
const { db } = useStorage();
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>());
const navigate = useNavigate();
const setHashtag = useOnboarding((state) => state.toggleHashtag);
const toggleTag = (tag: string) => {
if (tags.has(tag)) {
setTags((prev) => {
prev.delete(tag);
return new Set(prev);
});
} else {
if (tags.size >= 3) return;
setTags((prev) => new Set(prev.add(tag)));
}
};
const submit = async () => {
try {
setLoading(true);
for (const tag of tags) {
await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
}
setHashtag();
navigate(-1);
} catch (e) {
setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
</div>
Back
</button>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Choose {tags.size}/3 your favorite hashtag
</h1>
<div className="flex flex-col gap-4">
<div className="flex h-[420px] w-full flex-col overflow-y-auto rounded-xl bg-neutral-100 dark:bg-neutral-900">
{data.map((item: { hashtag: string }) => (
<button
key={item.hashtag}
type="button"
onClick={() => toggleTag(item.hashtag)}
className="inline-flex items-center justify-between px-4 py-2 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<p className="text-neutral-900 dark:text-neutral-100">{item.hashtag}</p>
{tags.has(item.hashtag) && (
<div>
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
</div>
)}
</button>
))}
</div>
<button
type="button"
onClick={submit}
disabled={loading || tags.size === 0}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>Adding...</span>
</>
) : (
<span>Add {tags.size} tags & Continue</span>
)}
</button>
</div>
</div>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { Outlet } from 'react-router-dom';
export function OnboardingScreen() {
return <Outlet />;
}
-58
View File
@@ -1,58 +0,0 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AllowNotification } from '@app/auth/components/features/allowNotification';
import { Circle } from '@app/auth/components/features/enableCircle';
import { OutboxModel } from '@app/auth/components/features/enableOutbox';
import { FavoriteHashtag } from '@app/auth/components/features/favoriteHashtag';
import { FollowList } from '@app/auth/components/features/followList';
import { SuggestFollow } from '@app/auth/components/features/suggestFollow';
import { LoaderIcon } from '@shared/icons';
export function OnboardingListScreen() {
const { state } = useLocation();
const { newuser }: { newuser: boolean } = state;
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const completed = () => {
setLoading(true);
const timeout = setTimeout(() => setLoading(false), 200);
clearTimeout(timeout);
navigate('/');
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl text-neutral-900 dark:text-neutral-100">
You&apos;re almost ready to use Lume.
</h1>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Let&apos;s start personalizing your experience.
</h2>
</div>
<div className="flex flex-col gap-3">
{newuser ? <SuggestFollow /> : <FollowList />}
<FavoriteHashtag />
<Circle />
<OutboxModel />
<AllowNotification />
<button
type="button"
onClick={completed}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : ' Continue'}
</button>
</div>
</div>
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
export function OnboardRelaysScreen() {
const navigate = useNavigate();
const toggleRelays = useOnboarding((state) => state.toggleRelays);
const [loading, setLoading] = useState(false);
const [relays, setRelays] = useState(new Set<string>());
const { db } = useStorage();
const { ndk } = useNDK();
const { getAllRelaysByUsers } = useNostr();
const { status, data } = useQuery(
['relays'],
async () => {
return await getAllRelaysByUsers();
},
{
refetchOnWindowFocus: false,
}
);
const toggleRelay = (relay: string) => {
if (relays.has(relay)) {
setRelays((prev) => {
prev.delete(relay);
return new Set(prev);
});
} else {
setRelays((prev) => new Set(prev.add(relay)));
}
};
const submit = async () => {
try {
setLoading(true);
for (const relay of relays) {
await db.createRelay(relay);
}
const tags = Array.from(relays).map((relay) => ['r', relay.replace(/\/+$/, '')]);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = 10002;
event.created_at = Math.floor(Date.now() / 1000);
event.tags = tags;
await event.publish();
toggleRelays();
navigate(-1);
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
</div>
Back
</button>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Relay discovery
</h1>
<div className="flex flex-col gap-4">
<div className="flex h-[420px] w-full flex-col overflow-y-auto rounded-xl bg-neutral-100 dark:bg-neutral-900">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
) : data.size === 0 ? (
<div className="flex h-full w-full items-center justify-center px-6">
<p className="text-center text-neutral-300 dark:text-neutral-600">
Lume couldn&apos;t find any relays from your follows.
<br />
You can skip this step and use default relays instead.
</p>
</div>
) : (
[...data].map(([key, value]) => (
<button
key={key}
type="button"
onClick={() => toggleRelay(key)}
className="inline-flex transform items-start justify-between px-4 py-2 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2">
<div className="pt-1.5">
{relays.has(key) ? (
<CheckCircleIcon className="h-4 w-4 text-teal-500" />
) : (
<CheckCircleIcon className="h-4 w-4 text-neutral-300 dark:text-neutral-700" />
)}
</div>
<p className="max-w-[15rem] truncate">{key.replace(/\/+$/, '')}</p>
</div>
<div className="inline-flex items-center gap-2">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
Used by
</span>
<div className="isolate flex -space-x-2">
{value.slice(0, 3).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{value.length > 3 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">+{value.length}</span>
</div>
) : null}
</div>
</div>
</div>
</button>
))
)}
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>Adding...</span>
</>
) : (
<span>Add {relays.size} relays & Continue</span>
)}
</button>
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Link } from 'react-router-dom';
export function TutorialFinishScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl font-light">
Yo, you&apos;ve understood basic features 🎉
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Start using
</Link>
<Link
to="https://nostr.how/"
target="_blank"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Learn more about Nostr
</Link>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
If you&apos;ve trouble when user Lume, you can report the issue{' '}
<a
href="github.com/luminous-devs/lume"
target="_blank"
className="text-blue-500 !underline"
>
here
</a>
</p>
</div>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
import { TextNote } from '@shared/notes';
export function TutorialNoteScreen() {
const { ndk } = useNDK();
const exampleEvent = new NDKEvent(ndk, {
id: 'a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821',
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
created_at: 1701355223,
kind: 1,
tags: [],
content: 'good morning nostr, stay humble and stack sats 🫡',
sig: '9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae',
});
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<EditIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
What is a <span className="font-bold">Note?</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<p className="px-3">
Posts on Nostr based Social Network client are usually called
&apos;Notes.&apos; Notes are arranged chronologically on the timeline and are
updated in real-time.
</p>
<p className="px-3 font-semibold">Here is one example:</p>
<TextNote event={exampleEvent} className="pointer-events-none my-2" />
<p className="px-3 font-semibold">Here are how you can interact with a note:</p>
<div className="flex flex-col gap-2 px-3">
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReplyIcon className="h-5 w-5 text-blue-500" />
</div>
<p>
Reply - Click on this button to reply to a note. It&apos;s also possible
to reply to replies, continuing the conversation like a thread.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReactionIcon className="h-5 w-5 text-red-500" />
</div>
<p>Reaction - You can add reactions to the Note to express your concern.</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<RepostIcon className="h-5 w-5 text-teal-500" />
</div>
<p>
Repost - You can share that note to your own timeline. You can also quote
them with your comments.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ZapIcon className="h-5 w-5 text-orange-500" />
</div>
<p>Zap - You can send tip in Bitcoin to that note owner with zero-fees</p>
</div>
</div>
<div className="mt-5 flex gap-2 px-3">
<Link
to="/auth/finish"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Back
</Link>
<Link
to="/auth/tutorials/widget"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function TutorialPostingScreen() {
return <div></div>;
}
+64
View File
@@ -0,0 +1,64 @@
import { Link } from 'react-router-dom';
import { BellIcon, HomeIcon, PlusIcon } from '@shared/icons';
export function TutorialWidgetScreen() {
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
The concept of <span className="font-bold">Widgets</span>
</h1>
</div>
<div className="flex flex-col gap-2 px-3">
<p>
Lume provides multiple widgets based on usage. You always can control what you
need to show on your Home.
</p>
<p className="font-semibold">Default widgets:</p>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<p>Newsfeed - You can view notes from accounts you follow.</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<BellIcon className="h-5 w-5" />
</div>
<p>Notification - You can view all notifications related to your account.</p>
</div>
<p>
If you want to add more widget, you can click to this button on Home Screen.
</p>
<div className="flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<button
type="button"
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-5 flex gap-2">
<Link
to="/auth/tutorials/note"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Back
</Link>
<Link
to="/auth/tutorials/finish"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}
+3 -3
View File
@@ -7,19 +7,19 @@ export function WelcomeScreen() {
<div className="text-center"> <div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" /> <img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl"> <h1 className="text-2xl">
Welcome to <span className="font-semibold">Lume</span> Welcome to <span className="font-bold">Lume</span>
</h1> </h1>
</div> </div>
<div className="flex flex-col gap-2 px-8"> <div className="flex flex-col gap-2 px-8">
<Link <Link
to="/auth/create" to="/auth/create"
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600" className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
> >
Create new account Create new account
</Link> </Link>
<Link <Link
to="/auth/import" to="/auth/import"
className="inline-flex h-10 w-full items-center justify-center rounded-lg font-medium text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900" className="inline-flex h-11 w-full items-center justify-center rounded-lg font-medium text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900"
> >
Log in Log in
</Link> </Link>
+40 -13
View File
@@ -1,5 +1,5 @@
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { VList, VListHandle } from 'virtua'; import { VList, VListHandle } from 'virtua';
@@ -16,20 +16,51 @@ import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function ChatScreen() { export function ChatScreen() {
const listRef = useRef<VListHandle>(null);
const { db } = useStorage(); const { db } = useStorage();
const { ndk } = useNDK(); const { ndk } = useNDK();
const { pubkey } = useParams(); const { pubkey } = useParams();
const { fetchNIP04Messages } = useNostr(); const { fetchNIP04Messages } = useNostr();
const { status, data } = useQuery(['nip04-dm', pubkey], async () => { const { status, data } = useQuery({
return await fetchNIP04Messages(pubkey); queryKey: ['nip04-dm', pubkey],
queryFn: async () => {
return await fetchNIP04Messages(pubkey);
},
refetchOnWindowFocus: false,
});
const queryClient = useQueryClient();
const listRef = useRef<VListHandle>(null);
const newMessage = useMutation({
mutationFn: async (event: NDKEvent) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['nip04-dm', pubkey] });
// Snapshot the previous value
const prevMessages = queryClient.getQueryData(['nip04-dm', pubkey]);
// Optimistically update to the new value
queryClient.setQueryData(['nip04-dm', pubkey], (prev: NDKEvent[]) => [
...prev,
event,
]);
// Return a context object with the snapshotted value
return { prevMessages };
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['nip04-dm', pubkey] });
},
}); });
const renderItem = useCallback( const renderItem = useCallback(
(message: NDKEvent) => { (message: NDKEvent) => {
return ( return (
<ChatMessage message={message} self={message.pubkey === db.account.pubkey} /> <ChatMessage
key={message.id}
message={message}
isSelf={message.pubkey === db.account.pubkey}
/>
); );
}, },
[data] [data]
@@ -53,7 +84,7 @@ export function ChatScreen() {
); );
sub.addListener('event', (event) => { sub.addListener('event', (event) => {
console.log(event); newMessage.mutate(event);
}); });
return () => { return () => {
@@ -69,7 +100,7 @@ export function ChatScreen() {
<User pubkey={pubkey} variant="simple" /> <User pubkey={pubkey} variant="simple" />
</div> </div>
<div className="h-full w-full flex-1 px-3 py-3"> <div className="h-full w-full flex-1 px-3 py-3">
{status === 'loading' ? ( {status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-1.5"> <div className="flex flex-col items-center gap-1.5">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" /> <LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
@@ -92,11 +123,7 @@ export function ChatScreen() {
)} )}
</div> </div>
<div className="shrink-0 rounded-b-lg border-t border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800"> <div className="shrink-0 rounded-b-lg border-t border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800">
<ChatForm <ChatForm receiverPubkey={pubkey} />
receiverPubkey={pubkey}
userPubkey={db.account.pubkey}
userPrivkey={''}
/>
</div> </div>
</div> </div>
</div> </div>
+18 -26
View File
@@ -1,6 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
import { nip04 } from 'nostr-tools'; import { useState } from 'react';
import { useCallback, useState } from 'react'; import { toast } from 'sonner';
import { MediaUploader } from '@app/chats/components/mediaUploader'; import { MediaUploader } from '@app/chats/components/mediaUploader';
@@ -8,34 +8,26 @@ import { useNDK } from '@libs/ndk/provider';
import { EnterIcon } from '@shared/icons'; import { EnterIcon } from '@shared/icons';
export function ChatForm({ export function ChatForm({ receiverPubkey }: { receiverPubkey: string }) {
receiverPubkey,
userPrivkey,
}: {
receiverPubkey: string;
userPubkey: string;
userPrivkey: string;
}) {
const { ndk } = useNDK(); const { ndk } = useNDK();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const encryptMessage = useCallback(async () => {
return await nip04.encrypt(userPrivkey, receiverPubkey, value);
}, [receiverPubkey, value]);
const submit = async () => { const submit = async () => {
const message = await encryptMessage(); try {
const tags = [['p', receiverPubkey]]; const recipient = new NDKUser({ pubkey: receiverPubkey });
const message = await ndk.signer.encrypt(recipient, value);
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = message; event.content = message;
event.kind = NDKKind.EncryptedDirectMessage; event.kind = NDKKind.EncryptedDirectMessage;
event.tags = tags; event.tag(recipient);
await event.publish(); const publish = await event.publish();
// reset state if (publish) setValue('');
setValue(''); } catch (e) {
toast.error(e);
}
}; };
const handleEnterPress = (e: { const handleEnterPress = (e: {
@@ -61,8 +53,8 @@ export function ChatForm({
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="Message" placeholder="Message..."
className="h-10 flex-1 resize-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:outline-none dark:text-neutral-100 dark:placeholder:text-neutral-300" className="h-10 flex-1 resize-none border-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:border-none focus:shadow-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-300"
/> />
<button <button
type="button" type="button"
+2 -2
View File
@@ -12,14 +12,14 @@ import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey'; import { displayNpub } from '@utils/shortenKey';
export const ChatListItem = memo(function ChatListItem({ event }: { event: NDKEvent }) { export const ChatListItem = memo(function ChatListItem({ event }: { event: NDKEvent }) {
const { status, user } = useProfile(event.pubkey); const { isLoading, user } = useProfile(event.pubkey);
const decryptedContent = useDecryptMessage(event); const decryptedContent = useDecryptMessage(event);
const createdAt = formatCreatedAt(event.created_at, true); const createdAt = formatCreatedAt(event.created_at, true);
const svgURI = const svgURI =
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50)); 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50));
if (status === 'loading') { if (isLoading) {
return ( return (
<div className="flex items-center gap-2.5 rounded-md px-3"> <div className="flex items-center gap-2.5 rounded-md px-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-400 dark:bg-neutral-600" /> <div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-400 dark:bg-neutral-600" />
+6 -4
View File
@@ -15,11 +15,13 @@ export function MediaUploader({
const uploadMedia = async () => { const uploadMedia = async () => {
setLoading(true); setLoading(true);
const image = await upload(null);
if (image.url) { const image = await upload(['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov']);
setState((prev: string) => `${prev}\n${image.url}`);
if (image) {
setState((prev: string) => `${prev}\n${image}`);
setLoading(false);
} }
setLoading(false);
}; };
return ( return (
+4 -20
View File
@@ -3,38 +3,22 @@ import { twMerge } from 'tailwind-merge';
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage'; import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
import { ImagePreview, LinkPreview, MentionNote, VideoPreview } from '@shared/notes'; export function ChatMessage({ message, isSelf }: { message: NDKEvent; isSelf: boolean }) {
import { parser } from '@utils/parser';
export function ChatMessage({ message, self }: { message: NDKEvent; self: boolean }) {
const decryptedContent = useDecryptMessage(message); const decryptedContent = useDecryptMessage(message);
const richContent = parser(decryptedContent) ?? null;
return ( return (
<div <div
className={twMerge( className={twMerge(
'my-2 w-max max-w-[400px] rounded-t-xl px-3 py-3', 'my-2 w-max max-w-[400px] rounded-t-xl px-3 py-3',
self isSelf
? 'ml-auto rounded-l-xl bg-blue-500 text-white' ? 'ml-auto rounded-l-xl bg-blue-500 text-white'
: 'rounded-r-xl bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100' : 'rounded-r-xl bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
)} )}
> >
{!richContent ? ( {!decryptedContent ? (
<p>Decrypting...</p> <p>Decrypting...</p>
) : ( ) : (
<div> <p className="select-text whitespace-pre-line break-all">{decryptedContent}</p>
<p className="select-text whitespace-pre-line">{richContent.parsed}</p>
<div>
{richContent.images.length > 0 && <ImagePreview urls={richContent.images} />}
{richContent.videos.length > 0 && <VideoPreview urls={richContent.videos} />}
{richContent.links.length > 0 && <LinkPreview urls={richContent.links} />}
{richContent.notes.length > 0 &&
richContent.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))}
</div>
</div>
)} )}
</div> </div>
); );
+1 -1
View File
@@ -14,7 +14,7 @@ export function useDecryptMessage(message: NDKEvent) {
async function decryptContent() { async function decryptContent() {
try { try {
const sender = new NDKUser({ const sender = new NDKUser({
hexpubkey: pubkey:
db.account.pubkey === message.pubkey db.account.pubkey === message.pubkey
? message.tags.find((el) => el[0] === 'p')[1] ? message.tags.find((el) => el[0] === 'p')[1]
: message.pubkey, : message.pubkey,
+20 -13
View File
@@ -1,28 +1,31 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react'; import { useCallback, useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { ChatListItem } from '@app/chats/components/chatListItem'; import { ChatListItem } from '@app/chats/components/chatListItem';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function ChatsScreen() { export function ChatsScreen() {
const navigate = useNavigate();
const { ndk } = useNDK();
const { getAllNIP04Chats } = useNostr(); const { getAllNIP04Chats } = useNostr();
const { status, data } = useQuery( const { status, data } = useQuery({
['nip04-chats'], queryKey: ['nip04-chats'],
async () => { queryFn: async () => {
return await getAllNIP04Chats(); return await getAllNIP04Chats();
}, },
{ refetchOnWindowFocus: false,
refetchOnWindowFocus: false, refetchOnMount: false,
refetchOnMount: false, refetchOnReconnect: false,
refetchOnReconnect: false, staleTime: Infinity,
staleTime: Infinity, });
}
);
const renderItem = useCallback( const renderItem = useCallback(
(event: NDKEvent) => { (event: NDKEvent) => {
@@ -31,6 +34,10 @@ export function ChatsScreen() {
[data] [data]
); );
useEffect(() => {
if (!ndk.signer) navigate('/new/privkey');
}, []);
return ( return (
<div className="grid h-full w-full grid-cols-3"> <div className="grid h-full w-full grid-cols-3">
<div className="col-span-1 h-full overflow-y-auto border-r border-neutral-200 scrollbar-none dark:border-neutral-800"> <div className="col-span-1 h-full overflow-y-auto border-r border-neutral-200 scrollbar-none dark:border-neutral-800">
@@ -43,7 +50,7 @@ export function ChatsScreen() {
</h3> </h3>
</div> </div>
<div className="flex h-full flex-col gap-1"> <div className="flex h-full flex-col gap-1">
{status === 'loading' ? ( {status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center pb-16"> <div className="flex h-full w-full items-center justify-center pb-16">
<div className="inline-flex flex-col items-center justify-center gap-2"> <div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" /> <LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
+132 -72
View File
@@ -1,93 +1,153 @@
import { useEffect, useState } from 'react'; import { downloadDir } from '@tauri-apps/api/path';
import { useLocation, useRouteError } from 'react-router-dom'; import { message, save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { relaunch } from '@tauri-apps/plugin-process';
import { useRouteError } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
interface RouteError { interface RouteError {
statusText: string; statusText: string;
message: string; message: string;
} }
interface DebugInfo {
os: null | string;
version: null | string;
appDir: null | string;
}
export function ErrorScreen() { export function ErrorScreen() {
const { db } = useStorage();
const error = useRouteError() as RouteError; const error = useRouteError() as RouteError;
const location = useLocation();
const [debugInfo, setDebugInfo] = useState<DebugInfo>({ const restart = async () => {
os: null, await relaunch();
version: null, };
appDir: null,
});
useEffect(() => { const download = async () => {
async function getInformation() { try {
const { platform, version } = await import('@tauri-apps/plugin-os'); const downloadPath = await downloadDir();
const { getVersion } = await import('@tauri-apps/plugin-app'); const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const { appConfigDir } = await import('@tauri-apps/api/path'); const filePath = await save({
defaultPath: downloadPath + '/' + fileName,
const platformName = await platform();
const osVersion = await version();
const appVersion = await getVersion();
const appDir = await appConfigDir();
setDebugInfo({
os: platformName + ' ' + osVersion,
version: appVersion,
appDir: appDir,
}); });
} const nsec = await db.secureLoad(db.account.pubkey);
getInformation(); if (filePath) {
}, []); if (nsec) {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${db.account.id}\nPrivate key: ${nsec}`
);
} else {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${db.account.id}`
);
}
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
}
};
return ( return (
<div className="flex h-full items-center justify-center"> <div
<div className="flex w-full flex-col gap-4 px-4 md:max-w-lg md:px-0"> data-tauri-drag-region
className="relative flex h-screen w-screen items-center justify-center bg-blue-600"
>
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="mb-1 text-2xl font-semibold text-white"> <h1 className="mb-3 text-4xl font-semibold text-blue-400">
Sorry, an unexpected error has occurred. Sorry, an unexpected error has occurred.
</h1> </h1>
<div className="mt-4 inline-flex h-16 items-center justify-center rounded-xl border border-dashed border-red-400 bg-red-200/10 px-5"> <h3 className="text-3xl font-semibold leading-snug text-white">
<p className="select-text text-sm font-medium text-red-400"> Don&apos;t panic, your account is safe.
{error.statusText || error.message} <br />
</p> Here are what things you can do:
</div> </h3>
<div className="mt-4">
<p className="font-medium text-neutral-600 dark:text-neutral-400">
Current location: {location.pathname}
</p>
<p className="font-medium text-neutral-600 dark:text-neutral-400">
App version: {debugInfo.version}
</p>
<p className="font-medium text-neutral-600 dark:text-neutral-400">
Platform: {debugInfo.os}
</p>
</div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex w-full flex-col gap-3">
<a <div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
href="https://github.com/luminous-devs/lume/issues/new" <div className="text-xl font-semibold text-white">
target="_blank" 1. Try to close and re-open the app
rel="noreferrer" </div>
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10" <button
> type="button"
Click here to report the issue on GitHub onClick={() => restart()}
</a> className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
<button >
type="button" Restart
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10" </button>
> </div>
Reload app <div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
</button> <div className="text-xl font-semibold text-white">
<button 2. Backup Nostr account
type="button" </div>
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10" <button
> type="button"
Reset app onClick={() => download()}
</button> className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Download
</button>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center justify-between">
<div className="text-xl font-semibold text-white">
3. Report this issue to Lume&apos;s Devs
</div>
<a
href="https://github.com/luminous-devs/lume/issues/new"
target="_blank"
rel="noreferrer"
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Report
</a>
</div>
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
<p className="select-text break-all text-red-400">
{error.statusText || error.message}
</p>
</div>
</div>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-1.5">
<div className="text-xl font-semibold text-white">
4. Use another Nostr client
</div>
<div className="select-text text-lg font-medium text-blue-300">
<p>
While waiting for Lume&apos;s Devs to release the bug fixes, you always
can use other Nostr clients with your account:
</p>
<div className="mt-2 flex flex-col gap-1 text-white">
<a
className="hover:!underline"
href="https://snort.social"
target="_blank"
rel="noreferrer"
>
snort.social
</a>
<a
className="hover:!underline"
href="https://primal.net"
target="_blank"
rel="noreferrer"
>
primal.net
</a>
<a
className="hover:!underline"
href="https://nostrudel.ninja"
target="_blank"
rel="noreferrer"
>
nostrudel.ninja
</a>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -3,9 +3,9 @@ import { memo } from 'react';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) { export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey); const { isLoading, user } = useProfile(pubkey);
if (status === 'loading') { if (isLoading) {
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />; return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
} }
+11 -34
View File
@@ -3,52 +3,29 @@ import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
ArticleNote,
FileNote,
NoteWrapper,
Repost,
TextNote,
UnknownNote,
} from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function UserLatestPosts({ pubkey }: { pubkey: string }) { export function UserLatestPosts({ pubkey }: { pubkey: string }) {
const { getEventsByPubkey } = useNostr(); const { getEventsByPubkey } = useNostr();
const { status, data } = useQuery(['user-posts', pubkey], async () => { const { status, data } = useQuery({
return await getEventsByPubkey(pubkey); queryKey: ['user-posts', pubkey],
queryFn: async () => {
return await getEventsByPubkey(pubkey);
},
refetchOnWindowFocus: false,
}); });
const renderItem = useCallback( const renderItem = useCallback(
(event: NDKEvent) => { (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return ( return <MemoizedTextNote key={event.id} event={event} />;
<NoteWrapper key={event.id} event={event}>
<TextNote />
</NoteWrapper>
);
case NDKKind.Repost: case NDKKind.Repost:
return <Repost key={event.id} event={event} />; return <MemoizedRepost key={event.id} event={event} />;
case 1063:
return (
<NoteWrapper key={event.id} event={event}>
<FileNote />
</NoteWrapper>
);
case NDKKind.Article:
return (
<NoteWrapper key={event.id} event={event}>
<ArticleNote />
</NoteWrapper>
);
default: default:
return ( return <UnknownNote key={event.id} event={event} />;
<NoteWrapper key={event.id} event={event}>
<UnknownNote />
</NoteWrapper>
);
} }
}, },
[data] [data]
@@ -60,7 +37,7 @@ export function UserLatestPosts({ pubkey }: { pubkey: string }) {
Latest post Latest post
</h3> </h3>
<div> <div>
{status === 'loading' ? ( {status === 'pending' ? (
<div className="px-3"> <div className="px-3">
<div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-neutral-300 text-sm font-medium dark:bg-neutral-700"> <div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-neutral-300 text-sm font-medium dark:bg-neutral-700">
<LoaderIcon className="h-4 w-4 animate-spin" /> <LoaderIcon className="h-4 w-4 animate-spin" />
+8 -10
View File
@@ -23,15 +23,15 @@ export const UserWithDrawer = memo(function UserWithDrawer({
}) { }) {
const { db } = useStorage(); const { db } = useStorage();
const { ndk } = useNDK(); const { ndk } = useNDK();
const { status, user } = useProfile(pubkey); const { isLoading, user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const follow = async (pubkey: string) => { const follow = async (pubkey: string) => {
try { try {
const user = ndk.getUser({ hexpubkey: db.account.pubkey }); const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows(); const contacts = await user.follows();
const add = await user.follow(new NDKUser({ hexpubkey: pubkey }), contacts); const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
if (add) { if (add) {
setFollowed(true); setFollowed(true);
@@ -45,14 +45,12 @@ export const UserWithDrawer = memo(function UserWithDrawer({
const unfollow = async (pubkey: string) => { const unfollow = async (pubkey: string) => {
try { try {
const user = ndk.getUser({ hexpubkey: db.account.pubkey }); const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows(); const contacts = await user.follows();
contacts.delete(new NDKUser({ hexpubkey: pubkey })); contacts.delete(new NDKUser({ pubkey: pubkey }));
let list: string[][]; let list: string[][];
contacts.forEach((el) => contacts.forEach((el) => list.push(['p', el.pubkey, el.relayUrls?.[0] || '', '']));
list.push(['p', el.pubkey, el.relayUrls?.[0] || '', el.profile?.name || ''])
);
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = ''; event.content = '';
@@ -69,7 +67,7 @@ export const UserWithDrawer = memo(function UserWithDrawer({
}; };
useEffect(() => { useEffect(() => {
if (db.account.follows.includes(pubkey)) { if (db.account.contacts.includes(pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);
@@ -84,7 +82,7 @@ export const UserWithDrawer = memo(function UserWithDrawer({
<Dialog.Portal> <Dialog.Portal>
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] animate-slideRightAndFade items-center justify-center px-4 pb-4 pt-16 transition-all"> <Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] animate-slideRightAndFade items-center justify-center px-4 pb-4 pt-16 transition-all">
<div className="h-full w-full overflow-y-auto rounded-lg border border-neutral-300 bg-neutral-200 py-3 dark:border-neutral-700 dark:bg-neutral-800"> <div className="h-full w-full overflow-y-auto rounded-lg border border-neutral-300 bg-neutral-200 py-3 dark:border-neutral-700 dark:bg-neutral-800">
{status === 'loading' ? ( {isLoading ? (
<div> <div>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
+1 -1
View File
@@ -28,7 +28,7 @@ export function ExploreScreen() {
const { getContactsByPubkey } = useNostr(); const { getContactsByPubkey } = useNostr();
const { project } = useReactFlow(); const { project } = useReactFlow();
const defaultContacts = useMemo(() => getMultipleRandom(db.account.follows, 10), []); const defaultContacts = useMemo(() => getMultipleRandom(db.account.contacts, 10), []);
const reactFlowWrapper = useRef(null); const reactFlowWrapper = useRef(null);
const connectingNodeId = useRef(null); const connectingNodeId = useRef(null);
+139
View File
@@ -0,0 +1,139 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react';
import { VList, VListHandle } from 'virtua';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import {
ArticleWidget,
FileWidget,
GroupWidget,
HashtagWidget,
NewsfeedWidget,
NotificationWidget,
ThreadWidget,
ToggleWidgetList,
TopicWidget,
TrendingAccountsWidget,
TrendingNotesWidget,
UserWidget,
WidgetList,
} from '@shared/widgets';
import { WIDGET_KIND } from '@utils/constants';
import { Widget } from '@utils/types';
export function HomeScreen() {
const ref = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { db } = useStorage();
const { status, data } = useQuery({
queryKey: ['widgets'],
queryFn: async () => {
const dbWidgets = await db.getWidgets();
const defaultWidgets = [
{
id: '9999',
title: 'Newsfeed',
content: '',
kind: WIDGET_KIND.newsfeed,
},
{
id: '9998',
title: 'Notification',
content: '',
kind: WIDGET_KIND.notification,
},
];
return [...defaultWidgets, ...dbWidgets];
},
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
const renderItem = useCallback((widget: Widget) => {
switch (widget.kind) {
case WIDGET_KIND.notification:
return <NotificationWidget key={widget.id} />;
case WIDGET_KIND.newsfeed:
return <NewsfeedWidget key={widget.id} />;
case WIDGET_KIND.topic:
return <TopicWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.user:
return <UserWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.thread:
return <ThreadWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.article:
return <ArticleWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.file:
return <FileWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.hashtag:
return <HashtagWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.group:
return <GroupWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.trendingNotes:
return <TrendingNotesWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.trendingAccounts:
return <TrendingAccountsWidget key={widget.id} widget={widget} />;
case WIDGET_KIND.list:
return <WidgetList key={widget.id} widget={widget} />;
default:
return null;
}
}, []);
if (status === 'pending') {
return (
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-black">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
);
}
return (
<VList
ref={ref}
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
initialItemSize={420}
tabIndex={0}
horizontal
onKeyDown={(e) => {
if (!ref.current) return;
switch (e.code) {
case 'ArrowUp':
case 'ArrowLeft': {
e.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
ref.current.scrollToIndex(prevIndex, {
align: 'center',
smooth: true,
});
break;
}
case 'ArrowDown':
case 'ArrowRight': {
e.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
setSelectedIndex(nextIndex);
ref.current.scrollToIndex(nextIndex, {
align: 'center',
smooth: true,
});
break;
}
default:
break;
}
}}
>
{data.map((widget) => renderItem(widget))}
<ToggleWidgetList />
</VList>
);
}
+136 -117
View File
@@ -1,10 +1,11 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NDKTag } from '@nostr-dev-kit/ndk';
import CharacterCount from '@tiptap/extension-character-count'; import CharacterCount from '@tiptap/extension-character-count';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react'; import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { useMemo, useState } from 'react'; import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Markdown } from 'tiptap-markdown'; import { Markdown } from 'tiptap-markdown';
@@ -26,11 +27,14 @@ import {
export function NewArticleScreen() { export function NewArticleScreen() {
const { ndk } = useNDK(); const { ndk } = useNDK();
const [height, setHeight] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [summary, setSummary] = useState({ open: false, content: '' }); const [summary, setSummary] = useState({ open: false, content: '' });
const [cover, setCover] = useState(''); const [cover, setCover] = useState('');
const navigate = useNavigate();
const containerRef = useRef(null);
const ident = useMemo(() => String(Date.now()), []); const ident = useMemo(() => String(Date.now()), []);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -65,13 +69,15 @@ export function NewArticleScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey');
setLoading(true); setLoading(true);
// get markdown content // get markdown content
const content = editor.storage.markdown.getMarkdown(); const content = editor.storage.markdown.getMarkdown();
// define tags // define tags
const tags: string[][] = [ const tags: NDKTag[] = [
['d', ident], ['d', ident],
['title', title], ['title', title],
['image', cover], ['image', cover],
@@ -85,17 +91,20 @@ export function NewArticleScreen() {
tags.push(['t', tag.replace('#', '')]); tags.push(['t', tag.replace('#', '')]);
}); });
// publish message
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = content; event.content = content;
event.kind = NDKKind.Article; event.kind = NDKKind.Article;
event.tags = tags; event.tags = tags;
// publish
const publishedRelays = await event.publish(); const publishedRelays = await event.publish();
if (publishedRelays) { if (publishedRelays) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`); toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
// update state // update state
setLoading(false); setLoading(false);
// reset editor // reset editor
editor.commands.clearContent(); editor.commands.clearContent();
localStorage.setItem('editor-article', '{}'); localStorage.setItem('editor-article', '{}');
@@ -106,123 +115,133 @@ export function NewArticleScreen() {
} }
}; };
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return ( return (
<div className="flex h-full flex-col justify-between"> <div className="flex flex-1 flex-col justify-between">
<div className="flex flex-col gap-4"> <div className="flex-1 overflow-y-auto">
{cover ? ( <div
<img className="flex flex-col gap-4"
src={cover} ref={containerRef}
alt="post cover" style={{ height: `${height}px` }}
className="h-72 w-full rounded-lg object-cover" >
/> {cover ? (
) : null} <img
<div className="group flex justify-between gap-2"> src={cover}
<input alt="post cover"
name="title" className="h-72 w-full rounded-lg object-cover"
className="h-9 flex-1 border-none bg-transparent text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600" />
placeholder="Untitled" ) : null}
value={title} <div className="group flex justify-between gap-2">
onChange={(e) => setTitle(e.target.value)} <input
/> name="title"
<div className="h-9 flex-1 border-none bg-transparent px-0 text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 focus:border-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-600"
className={twMerge( placeholder="Untitled"
'inline-flex shrink-0 gap-2 group-hover:inline-flex', value={title}
title.length > 0 ? '' : 'hidden' onChange={(e) => setTitle(e.target.value)}
)} />
> <div
<ArticleCoverUploader setCover={setCover} /> className={twMerge(
<button 'inline-flex shrink-0 gap-2 group-hover:inline-flex',
type="button" title.length > 0 ? '' : 'hidden'
onClick={() => setSummary((prev) => ({ ...prev, open: !prev.open }))} )}
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
> >
<ThreadsIcon className="h-4 w-4" /> <ArticleCoverUploader setCover={setCover} />
Add summary <button
</button> type="button"
</div> onClick={() => setSummary((prev) => ({ ...prev, open: !prev.open }))}
</div> className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
{summary.open ? ( >
<div className="flex gap-3"> <ThreadsIcon className="h-4 w-4" />
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" /> Add summary
<div className="flex-1"> </button>
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
</div> </div>
</div> </div>
) : null} {summary.open ? (
<div> <div className="flex gap-3">
{editor && ( <div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<FloatingMenu <div className="flex-1">
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
</div>
</div>
) : null}
<div>
{editor && (
<FloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 1 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 2 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 3 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('bold') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('italic') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor} editor={editor}
tippyOptions={{ duration: 100 }} spellCheck="false"
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900" autoComplete="off"
> autoCorrect="off"
<button autoCapitalize="off"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} />
className={twMerge( </div>
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 1 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 2 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 3 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('bold') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('italic') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div> </div>
</div> </div>
<div> <div>
@@ -235,7 +254,7 @@ export function NewArticleScreen() {
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900"> <div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-3"> <div className="inline-flex items-center gap-3">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400"> <span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} {editor?.storage?.characterCount.characters()} characters
</span> </span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400"> <span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
- -
+1
View File
@@ -2,3 +2,4 @@ export * from './articleCoverUploader';
export * from './mediaUploader'; export * from './mediaUploader';
export * from './mentionPopup'; export * from './mentionPopup';
export * from './mentionPopupItem'; export * from './mentionPopupItem';
export * from './mentionList';
+7 -45
View File
@@ -1,11 +1,13 @@
import { message, open } from '@tauri-apps/plugin-dialog'; import { message } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { Editor } from '@tiptap/react'; import { Editor } from '@tiptap/react';
import { useState } from 'react'; import { useState } from 'react';
import { MediaIcon } from '@shared/icons'; import { MediaIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({ editor }: { editor: Editor }) { export function MediaUploader({ editor }: { editor: Editor }) {
const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => { const uploadToNostrBuild = async () => {
@@ -13,52 +15,12 @@ export function MediaUploader({ editor }: { editor: Editor }) {
// start loading // start loading
setLoading(true); setLoading(true);
const selected = await open({ const image = await upload(['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov']);
multiple: false,
filters: [
{
name: 'Media',
extensions: [
'png',
'jpeg',
'jpg',
'gif',
'mp4',
'mp3',
'webm',
'mkv',
'avi',
'mov',
],
},
],
});
if (!selected) { if (image) {
setLoading(false); editor.commands.setImage({ src: image });
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append('fileToUpload', blob);
data.append('submit', 'Upload Image');
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
editor.commands.setImage({ src: content.url });
editor.commands.createParagraphNear(); editor.commands.createParagraphNear();
// stop loading
setLoading(false); setLoading(false);
} }
} catch (e) { } catch (e) {
+104
View File
@@ -0,0 +1,104 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { Ref, forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { NDKCacheUserProfile } from '@utils/types';
type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean;
};
const List = (
props: {
items: NDKCacheUserProfile[];
command: (arg0: { id: string }) => void;
},
ref: Ref<unknown>
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item.pubkey });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
{props.items.length ? (
props.items.map((item, index) => (
<button
key={index}
onClick={() => selectItem(index)}
className={twMerge(
'inline-flex h-11 items-center gap-2 rounded-md px-2',
index === selectedIndex ? 'bg-neutral-100 dark:bg-neutral-900' : ''
)}
>
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={150}>
<img
src={
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(item.name, 90, 50))
}
alt={item.name}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[150px] truncate text-sm font-medium">{item.name}</h5>
</button>
))
) : (
<div className="text-center text-sm font-medium">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);
+2 -2
View File
@@ -32,8 +32,8 @@ export function MentionPopup({ editor }: { editor: Editor }) {
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900" className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
> >
<div className="flex flex-col gap-1 py-1"> <div className="flex flex-col gap-1 py-1">
{db.account.follows.length > 0 ? ( {db.account.contacts.length > 0 ? (
db.account.follows.map((item) => ( db.account.contacts.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}> <button key={item} type="button" onClick={() => insertMention(item)}>
<MentionPopupItem pubkey={item} /> <MentionPopupItem pubkey={item} />
</button> </button>
+26 -9
View File
@@ -1,12 +1,18 @@
import { Image } from '@shared/image'; import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey'; import { displayNpub } from '@utils/shortenKey';
export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: string }) { export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
const { status, user } = useProfile(pubkey, embed); const { isLoading, user } = useProfile(pubkey, embed);
const svgURI = useMemo(
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
[pubkey]
);
if (status === 'loading') { if (isLoading) {
return ( return (
<div className="flex items-center gap-2.5 px-2"> <div className="flex items-center gap-2.5 px-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" /> <div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
@@ -20,14 +26,25 @@ export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: st
return ( return (
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800"> <div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
<Image <Avatar.Root className="shirnk-0 h-8 w-8">
src={user.picture || user.image} <Avatar.Image
alt={pubkey} src={user?.picture || user?.image}
className="shirnk-0 h-8 w-8 rounded-md object-cover" alt={pubkey}
/> loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start gap-px"> <div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100"> <h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100">
{user.display_name || user.displayName || user.name} {user?.display_name || user?.displayName || user?.name}
</h5> </h5>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400"> <span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
{displayNpub(pubkey, 16)} {displayNpub(pubkey, 16)}
+4
View File
@@ -2,6 +2,7 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
import { message, open } from '@tauri-apps/plugin-dialog'; import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs'; import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
@@ -10,6 +11,7 @@ import { LoaderIcon } from '@shared/icons';
export function NewFileScreen() { export function NewFileScreen() {
const { ndk } = useNDK(); const { ndk } = useNDK();
const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false); const [isPublish, setIsPublish] = useState(false);
@@ -84,6 +86,8 @@ export function NewFileScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey');
setIsPublish(true); setIsPublish(true);
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
+81 -63
View File
@@ -1,12 +1,14 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import CharacterCount from '@tiptap/extension-character-count'; import CharacterCount from '@tiptap/extension-character-count';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { useEffect, useState } from 'react'; import { nip19 } from 'nostr-tools';
import { useSearchParams } from 'react-router-dom'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { MediaUploader, MentionPopup } from '@app/new/components'; import { MediaUploader, MentionPopup } from '@app/new/components';
@@ -16,12 +18,21 @@ import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, LoaderIcon } from '@shared/icons'; import { CancelIcon, LoaderIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes'; import { MentionNote } from '@shared/notes';
import { WIDGET_KIND } from '@utils/constants';
import { useSuggestion } from '@utils/hooks/useSuggestion';
import { useWidget } from '@utils/hooks/useWidget';
export function NewPostScreen() { export function NewPostScreen() {
const { ndk, relayUrls } = useNDK(); const { ndk } = useNDK();
const { addWidget } = useWidget();
const { suggestion } = useSuggestion();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [height, setHeight] = useState(0);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const containerRef = useRef(null);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure(), StarterKit.configure(),
@@ -33,6 +44,14 @@ export function NewPostScreen() {
}, },
}), }),
CharacterCount.configure(), CharacterCount.configure(),
Mention.configure({
suggestion,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderLabel({ options, node }) {
const npub = nip19.npubEncode(node.attrs.id);
return `nostr:${npub}`;
},
}),
], ],
content: JSON.parse(localStorage.getItem('editor-post') || '{}'), content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
editorProps: { editorProps: {
@@ -49,13 +68,9 @@ export function NewPostScreen() {
const submit = async () => { const submit = async () => {
try { try {
setLoading(true); if (!ndk.signer) return navigate('/new/privkey');
const reply = { setLoading(true);
id: searchParams.get('id'),
root: searchParams.get('root'),
pubkey: searchParams.get('pubkey'),
};
// get plaintext content // get plaintext content
const html = editor.getHTML(); const html = editor.getHTML();
@@ -66,47 +81,44 @@ export function NewPostScreen() {
], ],
}); });
// define tags
let tags: string[][] = [];
// add reply to tags if present
if (reply.id && reply.pubkey) {
if (reply.root && reply.root.length > 1) {
tags = [
['e', reply.root, relayUrls[0], 'root'],
['e', reply.id, relayUrls[0], 'reply'],
['p', reply.pubkey],
];
} else {
tags = [
['e', reply.id, relayUrls[0], 'reply'],
['p', reply.pubkey],
];
}
}
// add hashtag to tags if present
const hashtags = serializedContent
.split(/\s/gm)
.filter((s: string) => s.startsWith('#'));
hashtags?.forEach((tag: string) => {
tags.push(['t', tag.replace('#', '')]);
});
// publish message
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = serializedContent; event.content = serializedContent;
event.kind = NDKKind.Text; event.kind = NDKKind.Text;
event.tags = tags;
// add reply to tags if present
const replyTo = searchParams.get('replyTo');
const rootReplyTo = searchParams.get('rootReplyTo');
if (rootReplyTo) {
const rootEvent = await ndk.fetchEvent(rootReplyTo);
event.tag(rootEvent, 'root');
}
if (replyTo) {
const replyEvent = await ndk.fetchEvent(replyTo);
event.tag(replyEvent, 'reply');
}
// publish event
const publishedRelays = await event.publish(); const publishedRelays = await event.publish();
if (publishedRelays) { if (publishedRelays) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`); toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
// update state // update state
setLoading(false); setLoading(false);
// reset editor
setSearchParams({}); setSearchParams({});
// open new widget with this event id
if (!replyTo) {
addWidget.mutate({
title: 'Thread',
content: event.id,
kind: WIDGET_KIND.thread,
});
}
// reset editor
editor.commands.clearContent(); editor.commands.clearContent();
localStorage.setItem('editor-post', '{}'); localStorage.setItem('editor-post', '{}');
} }
@@ -116,36 +128,42 @@ export function NewPostScreen() {
} }
}; };
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
useEffect(() => { useEffect(() => {
if (editor) editor.commands.focus('end'); if (editor) editor.commands.focus('end');
}, [editor]); }, [editor]);
return ( return (
<div className="flex h-full flex-col justify-between"> <div className="flex flex-1 flex-col gap-4">
<div> <div className="flex-1 overflow-y-auto">
<EditorContent <div ref={containerRef} style={{ height: `${height}px` }}>
editor={editor} <EditorContent
spellCheck="false" editor={editor}
autoComplete="off" spellCheck="false"
autoCorrect="off" autoComplete="off"
autoCapitalize="off" autoCorrect="off"
/> autoCapitalize="off"
{searchParams.get('id') && ( />
<div className="relative max-w-lg"> {searchParams.get('replyTo') && (
<MentionNote id={searchParams.get('id')} /> <div className="relative max-w-lg">
<button <MentionNote id={searchParams.get('replyTo')} editing />
type="button" <button
onClick={() => setSearchParams({})} type="button"
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-300 px-2 dark:bg-neutral-700" onClick={() => setSearchParams({})}
> className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
<CancelIcon className="h-4 w-4" /> >
</button> <CancelIcon className="h-5 w-5" />
</div> </button>
)} </div>
)}
</div>
</div> </div>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900"> <div className="inline-flex h-16 w-full items-center justify-between border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-950">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400"> <span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} {editor?.storage?.characterCount.characters()} characters
</span> </span>
<div className="flex items-center"> <div className="flex items-center">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
+86
View File
@@ -0,0 +1,86 @@
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
export function NewPrivkeyScreen() {
const { db } = useStorage();
const { ndk } = useNDK();
const [nsec, setNsec] = useState('');
const navigate = useNavigate();
const save = async (content: string) => {
return await db.secureSave(db.account.pubkey, content);
};
const submit = async (isSave?: boolean) => {
try {
if (!nsec.startsWith('nsec1'))
return toast.info('You must enter a private key starts with nsec');
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') return toast.info('You must enter a valid nsec');
const privkey = decoded.data;
const pubkey = getPublicKey(privkey);
if (pubkey !== db.account.pubkey)
return toast.info(
'Your nsec is not match your current public key, please make sure you enter right nsec'
);
const signer = new NDKPrivateKeySigner(privkey);
ndk.signer = signer;
if (isSave) await save(privkey);
navigate(-1);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-16 flex flex-col gap-3">
<h1 className="text-center font-semibold text-neutral-900 dark:text-neutral-100">
You need to provide private key to sign nostr event.
</h1>
<input
name="privkey"
placeholder="nsec..."
type="password"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="mt-2 flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Submit
</button>
<button
type="button"
onClick={() => submit(true)}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Submit and Save
</button>
</div>
</div>
</div>
);
}
+100 -75
View File
@@ -1,101 +1,126 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import Markdown from 'markdown-to-jsx';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { AddressPointer, EventPointer } from 'nostr-tools/lib/types/nip19'; import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons'; import { ArrowLeftIcon, CheckCircleIcon, ShareIcon } from '@shared/icons';
import { ArticleDetailNote, NoteActions, NoteReplyForm } from '@shared/notes'; import { NoteReplyForm } from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list'; import { ReplyList } from '@shared/notes/replies/list';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
export function ArticleNoteScreen() { export function ArticleNoteScreen() {
const navigate = useNavigate();
const replyRef = useRef(null);
const { id } = useParams(); const { id } = useParams();
const { status, data } = useEvent(id);
const naddr = id.startsWith('naddr') ? (nip19.decode(id).data as AddressPointer) : null;
const { status, data } = useEvent(id, naddr);
const [isCopy, setIsCopy] = useState(false); const [isCopy, setIsCopy] = useState(false);
const share = async () => { const navigate = useNavigate();
await writeText( const metadata = useMemo(() => {
'https://njump.me/' + if (status === 'pending') return;
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
};
const scrollToReply = () => { const title = data.tags.find((tag) => tag[0] === 'title')?.[1];
replyRef.current.scrollIntoView(); const image = data.tags.find((tag) => tag[0] === 'image')?.[1];
const summary = data.tags.find((tag) => tag[0] === 'summary')?.[1];
let publishedAt: Date | string | number = data.tags.find(
(tag) => tag[0] === 'published_at'
)?.[1];
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
return {
title,
image,
publishedAt,
summary,
};
}, [data]);
const share = async () => {
try {
await writeText(
'https://njump.me/' +
nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
} catch (e) {
toast.error(e);
}
}; };
return ( return (
<div className="container mx-auto grid grid-cols-8 scroll-smooth px-4"> <div className="grid grid-cols-12 scroll-smooth px-4">
<div className="col-span-1"> <div className="col-span-1 flex flex-col items-start">
<div className="flex flex-col items-end gap-4"> <button
<button type="button"
type="button" onClick={() => navigate(-1)}
onClick={() => navigate(-1)} className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900" >
> <ArrowLeftIcon className="h-5 w-5" />
<ArrowLeftIcon className="h-5 w-5" /> </button>
</button> <button
<div className="flex flex-col divide-y divide-neutral-200 rounded-xl bg-neutral-100 dark:divide-neutral-800 dark:bg-neutral-900"> type="button"
<button onClick={share}
type="button" className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl"
onClick={share} >
className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl" {isCopy ? (
> <CheckCircleIcon className="h-5 w-5 text-teal-500" />
{isCopy ? (
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
) : (
<ShareIcon className="h-5 w-5" />
)}
</button>
<button
type="button"
onClick={scrollToReply}
className="inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
>
<ReplyIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="relative col-span-6 flex flex-col overflow-y-auto">
<div className="mx-auto w-full max-w-2xl">
{status === 'loading' ? (
<div className="px-3 py-1.5">Loading...</div>
) : ( ) : (
<div className="flex h-min w-full flex-col px-3"> <ShareIcon className="h-5 w-5" />
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900"> )}
<User pubkey={data.pubkey} time={data.created_at} variant="thread" /> </button>
<div className="mt-3"> </div>
<ArticleDetailNote event={data} /> <div className="col-span-7 overflow-y-auto px-3 xl:col-span-8">
</div> {status === 'pending' ? (
<div className="mt-3"> <div className="px-3 py-1.5">Loading...</div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} /> ) : (
</div> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 border-b border-neutral-100 pb-4 dark:border-neutral-900">
{metadata.image && (
<img
src={metadata.image}
alt={metadata.title}
className="h-auto w-full rounded-lg object-cover"
/>
)}
<div>
<h1 className="mb-2 text-3xl font-semibold">{metadata.title}</h1>
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Published: {metadata.publishedAt.toString()}
</span>
</div> </div>
</div> </div>
)} <Markdown
<div ref={replyRef} className="px-3"> options={{
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900"> overrides: {
<NoteReplyForm id={id} /> a: {
</div> props: {
<ReplyList id={id} /> className: 'text-blue-500 hover:text-blue-600',
target: '_blank',
},
},
},
}}
className="break-p prose-lg prose-neutral dark:prose-invert prose-ul:list-disc"
>
{data.content}
</Markdown>
</div> </div>
)}
</div>
<div className="col-span-4 border-l border-neutral-100 px-3 dark:border-neutral-900 xl:col-span-3">
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
<NoteReplyForm rootEvent={data} />
</div> </div>
<ReplyList eventId={id} />
</div> </div>
<div className="col-span-1" />
</div> </div>
); );
} }
+40 -23
View File
@@ -4,39 +4,45 @@ import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19'; import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons'; import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
import { import {
ArticleNote, ChildNote,
FileNote, MemoizedTextKind,
NoteActions, NoteActions,
NoteReplyForm, NoteReplyForm,
TextNote,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list'; import { ReplyList } from '@shared/notes/replies/list';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
import { useNostr } from '@utils/hooks/useNostr';
export function TextNoteScreen() { export function TextNoteScreen() {
const { id } = useParams();
const { status, data } = useEvent(id);
const navigate = useNavigate(); const navigate = useNavigate();
const replyRef = useRef(null); const replyRef = useRef(null);
const { id } = useParams();
const { status, data } = useEvent(id);
const { getEventThread } = useNostr();
const [isCopy, setIsCopy] = useState(false); const [isCopy, setIsCopy] = useState(false);
const share = async () => { const share = async () => {
await writeText( try {
'https://njump.me/' + await writeText(
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer) 'https://njump.me/' +
); nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
// update state );
setIsCopy(true); // update state
// reset state after 2 sec setIsCopy(true);
setTimeout(() => setIsCopy(false), 2000); // reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
} catch (e) {
toast.error(e);
}
}; };
const scrollToReply = () => { const scrollToReply = () => {
@@ -44,13 +50,24 @@ export function TextNoteScreen() {
}; };
const renderKind = (event: NDKEvent) => { const renderKind = (event: NDKEvent) => {
const thread = getEventThread(event.tags);
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return <TextNote content={event.content} />; return (
case NDKKind.Article: <>
return <ArticleNote event={event} />; {thread ? (
case 1063: <div className="mb-2 w-full px-3">
return <FileNote event={event} />; <div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? (
<ChildNote id={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
</div>
</div>
) : null}
<MemoizedTextKind content={event.content} />
</>
);
default: default:
return <UnknownNote event={event} />; return <UnknownNote event={event} />;
} }
@@ -91,7 +108,7 @@ export function TextNoteScreen() {
</div> </div>
<div className="relative col-span-6 flex flex-col overflow-y-auto"> <div className="relative col-span-6 flex flex-col overflow-y-auto">
<div className="mx-auto w-full max-w-2xl"> <div className="mx-auto w-full max-w-2xl">
{status === 'loading' ? ( {status === 'pending' ? (
<div className="px-3 py-1.5">Loading...</div> <div className="px-3 py-1.5">Loading...</div>
) : ( ) : (
<div className="flex h-min w-full flex-col px-3"> <div className="flex h-min w-full flex-col px-3">
@@ -99,16 +116,16 @@ export function TextNoteScreen() {
<User pubkey={data.pubkey} time={data.created_at} variant="thread" /> <User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-3">{renderKind(data)}</div> <div className="mt-3">{renderKind(data)}</div>
<div className="mt-3"> <div className="mt-3">
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} /> <NoteActions event={data} canOpenEvent={false} />
</div> </div>
</div> </div>
</div> </div>
)} )}
<div ref={replyRef} className="px-3"> <div ref={replyRef} className="px-3">
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900"> <div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
<NoteReplyForm id={id} /> <NoteReplyForm rootEvent={data} />
</div> </div>
<ReplyList id={id} /> <ReplyList eventId={id} />
</div> </div>
</div> </div>
</div> </div>
+10 -12
View File
@@ -43,21 +43,19 @@ export function NWCForm({ setWalletConnectURL }) {
return ( return (
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900"> <div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="flex flex-col gap-1.5"> <textarea
<textarea name="walletConnectURL"
name="walletConnectURL" value={uri}
value={uri} // eslint-disable-next-line jsx-a11y/no-autofocus
// eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={false}
autoFocus={false} onChange={(e) => setUri(e.target.value)}
onChange={(e) => setUri(e.target.value)} placeholder="nostr+walletconnect://"
placeholder="nostr+walletconnect://" className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
className="h-40 w-full resize-none rounded-lg bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400" />
/>
</div>
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600" className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
> >
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Connect'} {loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Connect'}
</button> </button>
+4 -4
View File
@@ -11,7 +11,7 @@ export function NWCScreen() {
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null); const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
const remove = async () => { const remove = async () => {
await db.secureRemove('nwc'); await db.secureRemove(`${db.account.pubkey}-nwc`);
setWalletConnectURL(null); setWalletConnectURL(null);
}; };
@@ -41,16 +41,16 @@ export function NWCScreen() {
<CheckCircleIcon className="h-4 w-4" /> <CheckCircleIcon className="h-4 w-4" />
<div>You&apos;re using nostr wallet connect</div> <div>You&apos;re using nostr wallet connect</div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3">
<textarea <textarea
readOnly readOnly
value={walletConnectURL.substring(0, 120) + '****'} value={walletConnectURL.substring(0, 120) + '****'}
className="relative h-40 w-full resize-none rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400" className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
onClick={() => remove()} onClick={() => remove()}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100" className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
> >
Remove connection Remove connection
</button> </button>
+27 -56
View File
@@ -1,90 +1,61 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
ArticleNote,
FileNote,
NoteWrapper,
Repost,
TextNote,
UnknownNote,
} from '@shared/notes';
export function RelayEventList({ relayUrl }: { relayUrl: string }) { export function RelayEventList({ relayUrl }: { relayUrl: string }) {
const { fetcher } = useNDK(); const { fetcher } = useNDK();
const { status, data } = useQuery( const { status, data } = useQuery({
['relay-event'], queryKey: ['relay-events', relayUrl],
async () => { queryFn: async () => {
const url = 'wss://' + relayUrl; const url = 'wss://' + relayUrl;
const events = await fetcher.fetchLatestEvents( const events = await fetcher.fetchLatestEvents(
[url], [normalizeRelayUrl(url)],
{ {
kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article], kinds: [NDKKind.Text, NDKKind.Repost],
}, },
100 20
); );
return events as unknown as NDKEvent[]; return events as unknown as NDKEvent[];
}, },
{ refetchOnWindowFocus: false } refetchOnWindowFocus: false,
); refetchOnReconnect: false,
refetchOnMount: false,
});
const renderItem = useCallback( const renderItem = useCallback(
(event: NDKEvent) => { (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return ( return <MemoizedTextNote key={event.id} event={event} />;
<NoteWrapper key={event.id} event={event}>
<TextNote />
</NoteWrapper>
);
case NDKKind.Repost: case NDKKind.Repost:
return <Repost key={event.id} event={event} />; return <MemoizedRepost key={event.id} event={event} />;
case 1063:
return (
<NoteWrapper key={event.id} event={event}>
<FileNote />
</NoteWrapper>
);
case NDKKind.Article:
return (
<NoteWrapper key={event.id} event={event}>
<ArticleNote />
</NoteWrapper>
);
default: default:
return ( return <UnknownNote key={event.id} event={event} />;
<NoteWrapper key={event.id} event={event}>
<UnknownNote />
</NoteWrapper>
);
} }
}, },
[data] [data]
); );
return ( return (
<div className="h-full"> <VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
<div className="mx-auto w-full max-w-[500px]"> {status === 'pending' ? (
{status === 'loading' ? ( <div className="flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center"> <div className="inline-flex flex-col items-center justify-center gap-2">
<div className="inline-flex flex-col items-center justify-center gap-2"> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
<p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
</div>
</div> </div>
) : ( </div>
<VList className="scrollbar-none h-full"> ) : (
<div className="h-10" /> data.map((item) => renderItem(item))
{data.map((item) => renderItem(item))} )}
<div className="h-16" /> <div className="h-20" />
</VList> </VList>
)}
</div>
</div>
); );
} }
+26 -30
View File
@@ -1,66 +1,62 @@
import { useQueryClient } from '@tanstack/react-query'; import { NDKRelayUrl } from '@nostr-dev-kit/ndk';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner';
import { useStorage } from '@libs/storage/provider';
import { PlusIcon } from '@shared/icons'; import { PlusIcon } from '@shared/icons';
import { useRelay } from '@utils/hooks/useRelay';
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/; const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
export function RelayForm() { export function RelayForm() {
const { db } = useStorage(); const { connectRelay } = useRelay();
const queryClient = useQueryClient(); const [relay, setRelay] = useState<{
url: NDKRelayUrl;
purpose: 'read' | 'write' | undefined;
}>({ url: '', purpose: undefined });
const [url, setUrl] = useState(''); const create = () => {
const [error, setError] = useState(''); if (relay.url.length < 1) return toast.info('Please enter relay url');
const createRelay = async () => {
if (url.length < 1) return setError('Please enter relay url');
try { try {
const relay = new URL(url.replace(/\s/g, '')); const relayUrl = new URL(relay.url.replace(/\s/g, ''));
if ( if (
domainRegex.test(relay.host) && domainRegex.test(relayUrl.host) &&
(relay.protocol === 'wss:' || relay.protocol === 'ws:') (relayUrl.protocol === 'wss:' || relayUrl.protocol === 'ws:')
) { ) {
const res = await db.createRelay(url); connectRelay.mutate(normalizeRelayUrl(relay.url));
if (!res) return setError("You're already using this relay"); setRelay({ url: '', purpose: undefined });
queryClient.invalidateQueries(['user-relay']);
setError('');
setUrl('');
} else { } else {
return setError( return toast.error(
'URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again' 'URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again'
); );
} }
} catch { } catch {
return setError('Relay URL is not valid. Please check again'); return toast.error('Relay URL is not valid. Please check again');
} }
}; };
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex h-10 items-center justify-between rounded-lg bg-neutral-200 pr-1.5 dark:bg-neutral-800"> <div className="flex gap-2">
<input <input
className="h-full w-full bg-transparent pl-3 pr-1.5 text-neutral-900 placeholder:text-neutral-600 focus:outline-none dark:text-neutral-100 dark:placeholder:text-neutral-400" className="h-11 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
type="url"
placeholder="wss://" placeholder="wss://"
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
value={url} value={relay.url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setRelay((prev) => ({ ...prev, url: e.target.value }))}
/> />
<button <button
type="button" type="button"
onClick={() => createRelay()} onClick={() => create()}
className="inline-flex h-6 w-6 items-center justify-center rounded bg-blue-500 text-white hover:bg-blue-600" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-5 w-5" />
</button> </button>
</div> </div>
<span className="text-sm text-red-400">{error}</span>
</div> </div>
); );
} }
+16 -32
View File
@@ -1,51 +1,37 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { message } from '@tauri-apps/plugin-dialog';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon, PlusIcon, ShareIcon } from '@shared/icons'; import { LoaderIcon, PlusIcon, ShareIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { useRelay } from '@utils/hooks/useRelay';
export function RelayList() { export function RelayList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { getAllRelaysByUsers } = useNostr(); const { getAllRelaysByUsers } = useNostr();
const { db } = useStorage(); const { connectRelay } = useRelay();
const { status, data } = useQuery( const { status, data } = useQuery({
['relays'], queryKey: ['relays'],
async () => { queryFn: async () => {
return await getAllRelaysByUsers(); return await getAllRelaysByUsers();
}, },
{ refetchOnWindowFocus: false,
refetchOnWindowFocus: false, refetchOnMount: false,
refetchOnMount: false, refetchOnReconnect: false,
refetchOnReconnect: false, staleTime: Infinity,
staleTime: Infinity, });
}
); const navigate = useNavigate();
const inspectRelay = (relayUrl: string) => { const inspectRelay = (relayUrl: string) => {
const url = new URL(relayUrl); const url = new URL(relayUrl);
navigate(`/relays/${url.hostname}`); navigate(`/relays/${url.hostname}`);
}; };
const connectRelay = async (relayUrl: string) => {
const url = normalizeRelayUrl(relayUrl);
const res = await db.createRelay(url);
if (!res) await message("You're aldready connected to this relay");
queryClient.invalidateQueries(['user-relay']);
};
return ( return (
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900"> <div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
{status === 'loading' ? ( {status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center pb-10"> <div className="flex h-full w-full items-center justify-center pb-10">
<div className="inline-flex flex-col items-center justify-center gap-2"> <div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" /> <LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
@@ -55,9 +41,7 @@ export function RelayList() {
) : ( ) : (
<VList className="h-full"> <VList className="h-full">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900"> <div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50"> <h3 className="font-semibold">Relay discovery</h3>
All relays used by your follows
</h3>
</div> </div>
{[...data].map(([key, value]) => ( {[...data].map(([key, value]) => (
<div <div
@@ -76,7 +60,7 @@ export function RelayList() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => connectRelay(key)} onClick={() => connectRelay.mutate(key)}
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800" className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
> >
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-3 w-3" />
-69
View File
@@ -1,69 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RelayForm } from '@app/relays/components/relayForm';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon } from '@shared/icons';
export function UserRelay() {
const queryClient = useQueryClient();
const { relayUrls } = useNDK();
const { db } = useStorage();
const { status, data } = useQuery(
['user-relay'],
async () => {
return await db.getExplicitRelayUrls();
},
{ refetchOnWindowFocus: false }
);
const removeRelay = async (relayUrl: string) => {
await db.removeRelay(relayUrl);
queryClient.invalidateQueries(['user-relay']);
};
return (
<div className="mt-3 px-3">
{status === 'loading' ? (
<p>Loading...</p>
) : (
<div className="flex flex-col gap-2">
{data.map((item) => (
<div
key={item}
className="group flex h-10 items-center justify-between rounded-lg bg-neutral-200 pl-3 pr-1.5 dark:bg-neutral-800"
>
<div className="inline-flex items-center gap-2.5">
{relayUrls.includes(item) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500"></span>
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item}
</p>
</div>
<button
type="button"
onClick={() => removeRelay(item)}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
))}
<RelayForm />
</div>
)}
</div>
);
}
@@ -0,0 +1,98 @@
import { NDKKind, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { RelayForm } from '@app/relays/components/relayForm';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, RefreshIcon } from '@shared/icons';
import { useRelay } from '@utils/hooks/useRelay';
export function UserRelayList() {
const { db } = useStorage();
const { ndk } = useNDK();
const { removeRelay } = useRelay();
const { status, data, refetch } = useQuery({
queryKey: ['relays', db.account.pubkey],
queryFn: async () => {
const event = await ndk.fetchEvent(
{
kinds: [NDKKind.RelayList],
authors: [db.account.pubkey],
},
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
);
if (!event) return [];
return event.tags;
},
refetchOnWindowFocus: false,
});
const currentRelays = new Set([...ndk.pool.relays.values()].map((item) => item.url));
return (
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold">Connected relays</h3>
<button
type="button"
onClick={() => refetch()}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RefreshIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex flex-col gap-2 px-3">
{status === 'pending' ? (
<p>Loading...</p>
) : !data.length ? (
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
<p className="text-sm font-medium">You not have personal relay list yet</p>
</div>
) : (
data.map((item) => (
<div
key={item[1]}
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
>
<div className="inline-flex items-baseline gap-2">
{currentRelays.has(item[1]) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500"></span>
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item[1]}
</p>
</div>
<div className="inline-flex items-center gap-2">
{item[2]?.length ? (
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
{item[2]}
</div>
) : null}
<button
type="button"
onClick={() => removeRelay.mutate(item[1])}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
</div>
))
)}
<RelayForm />
</div>
</div>
);
}
+2 -9
View File
@@ -1,18 +1,11 @@
import { RelayList } from '@app/relays/components/relayList'; import { RelayList } from '@app/relays/components/relayList';
import { UserRelay } from '@app/relays/components/userRelay'; import { UserRelayList } from '@app/relays/components/userRelayList';
export function RelaysScreen() { export function RelaysScreen() {
return ( return (
<div className="grid h-full w-full grid-cols-3"> <div className="grid h-full w-full grid-cols-3">
<RelayList /> <RelayList />
<div className="col-span-1"> <UserRelayList />
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
Connected relays
</h3>
</div>
<UserRelay />
</div>
</div> </div>
); );
} }
+4 -2
View File
@@ -4,6 +4,8 @@ import { Await, useLoaderData, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons'; import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { NIP11 } from '@utils/types';
import { RelayEventList } from './components/relayEventList'; import { RelayEventList } from './components/relayEventList';
export function RelayScreen() { export function RelayScreen() {
@@ -59,7 +61,7 @@ export function RelayScreen() {
</div> </div>
} }
> >
{(resolvedRelay) => ( {(resolvedRelay: NIP11) => (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div> <div>
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100"> <h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
@@ -114,7 +116,7 @@ export function RelayScreen() {
Supported NIPs: Supported NIPs:
</h5> </h5>
<div className="mt-2 grid grid-cols-7 gap-2"> <div className="mt-2 grid grid-cols-7 gap-2">
{resolvedRelay.supported_nips.map((item: string) => ( {resolvedRelay.supported_nips.map((item) => (
<a <a
key={item} key={item}
href={`https://nips.be/${item}`} href={`https://nips.be/${item}`}
+76
View File
@@ -0,0 +1,76 @@
import { getVersion } from '@tauri-apps/api/app';
import { relaunch } from '@tauri-apps/plugin-process';
import { Update, check } from '@tauri-apps/plugin-updater';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
export function AboutScreen() {
const [version, setVersion] = useState('');
const [newUpdate, setNewUpdate] = useState<Update>(null);
const checkUpdate = async () => {
const update = await check();
if (!update) toast.info('There is no update available');
setNewUpdate(update);
};
const installUpdate = async () => {
await newUpdate.downloadAndInstall();
await relaunch();
};
useEffect(() => {
async function loadVersion() {
const appVersion = await getVersion();
setVersion(appVersion);
}
loadVersion();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex items-center justify-center gap-2">
<img src="/icon.png" alt="Lume's logo" className="w-16 shrink-0" />
<div>
<h1 className="text-xl font-semibold">Lume</h1>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Version {version}
</p>
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
{!newUpdate ? (
<button
type="button"
onClick={() => checkUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Check for update
</button>
) : (
<button
type="button"
onClick={() => installUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Install {newUpdate.version}
</button>
)}
<Link
to="https://lume.nu"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Website
</Link>
<Link
to="https://github.com/luminous-devs/lume/issues"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Report a issue
</Link>
</div>
</div>
);
}
-138
View File
@@ -1,138 +0,0 @@
import { nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon } from '@shared/icons';
export function AccountSettingsScreen() {
const { db } = useStorage();
const [privType, setPrivType] = useState('password');
const [nsecType, setNsecType] = useState('password');
const privkey = 'todo';
const nsec = useMemo(() => nip19.nsecEncode(privkey), [privkey]);
const showPrivkey = () => {
if (privType === 'password') {
setPrivType('text');
} else {
setPrivType('password');
}
};
const showNsec = () => {
if (nsecType === 'password') {
setNsecType('text');
} else {
setNsecType('password');
}
};
return (
<div className="h-full w-full px-3 pt-11">
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold text-white">Account</h1>
<div className="flex flex-col gap-4 rounded-xl bg-white/10 p-3 backdrop-blur-xl">
<div className="flex flex-col gap-1">
<label
htmlFor="pubkey"
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
>
Public Key
</label>
<input
readOnly
value={db.account.pubkey}
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="npub"
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
>
Npub
</label>
<input
readOnly
value={db.account.npub}
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="privkey"
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
>
Private Key
</label>
<div className="relative w-full">
<input
readOnly
type={privType}
value={privkey}
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
/>
<button
type="button"
onClick={() => showPrivkey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-neutral-700"
>
{privType === 'password' ? (
<EyeOffIcon
width={20}
height={20}
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
/>
)}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="privkey"
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
>
Nsec
</label>
<div className="relative w-full">
<input
readOnly
type={nsecType}
value={nsec}
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
/>
<button
type="button"
onClick={() => showNsec()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-neutral-700"
>
{privType === 'password' ? (
<EyeOffIcon
width={20}
height={20}
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
/>
)}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useStorage } from '@libs/storage/provider';
export function AdvancedSettingScreen() {
const { db } = useStorage();
const clearCache = async () => {
await db.clearCache();
};
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Caches</div>
<div className="text-sm">Use for boost up NDK</div>
</div>
<button
type="button"
onClick={() => clearCache()}
className="h-8 w-max rounded-lg bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
Clear
</button>
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon } from '@shared/icons';
export function BackupSettingScreen() {
const { db } = useStorage();
const [privkey, setPrivkey] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const removePrivkey = async () => {
await db.secureRemove(db.account.pubkey);
};
useEffect(() => {
async function loadPrivkey() {
const key = await db.secureLoad(db.account.pubkey);
if (key) setPrivkey(key);
}
loadPrivkey();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="mb-2 text-sm font-semibold">Private key</div>
<div>
{!privkey ? (
<div className="inline-flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
You&apos;ve stored private key on Lume
</div>
) : (
<>
<div className="relative">
<input
readOnly
type={showPassword ? 'text' : 'password'}
value={nip19.nsecEncode(privkey)}
className="relative h-11 w-full resize-none rounded-lg border-none bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 transform items-center justify-center rounded-lg bg-neutral-50 dark:bg-neutral-950"
>
<EyeOffIcon className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => removePrivkey()}
className="mt-2 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-red-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
>
Remove private key
</button>
</>
)}
</div>
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
export function AutoStartSetting() {
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-neutral-200">Auto start</span>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
Auto start at login
</span>
</div>
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import { useState } from 'react';
import { CheckCircleIcon } from '@shared/icons';
export function CacheTimeSetting() {
const [time, setTime] = useState('0');
const update = async () => {
// await updateSetting('cache_time', time);
};
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-neutral-200">
Cache time (milliseconds)
</span>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
The length of time before inactive data gets removed from the cache
</span>
</div>
<div className="inline-flex items-center gap-2">
<input
value={time}
onChange={(e) => setTime(e.currentTarget.value)}
autoCapitalize="none"
autoCorrect="none"
className="h-8 w-24 rounded-md bg-neutral-800 px-2 text-right font-medium text-neutral-300 focus:outline-none"
/>
<button
type="button"
onClick={() => update()}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-neutral-800 font-medium hover:bg-blue-600"
>
<CheckCircleIcon className="h-4 w-4 text-white" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { EditIcon, LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
export function ContactCard() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['contacts'],
queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const follows = await user.follows();
return [...follows];
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.length)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Contacts
</p>
<Link
to="/settings/edit-contact"
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}
-28
View File
@@ -1,28 +0,0 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { useEffect, useState } from 'react';
export function DataPath() {
const [path, setPath] = useState<string>('');
useEffect(() => {
async function getPath() {
const dir = await appConfigDir();
setPath(dir);
}
getPath();
}, []);
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-neutral-200">App data path</span>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
Where the local data is stored
</span>
</div>
<div className="inline-flex items-center gap-2">
<span className="font-medium text-neutral-300">{path}</span>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
export function PostCard() {
const { db } = useStorage();
const { status, data } = useQuery({
queryKey: ['user-stats', db.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${db.account.pubkey}`,
{
signal,
}
);
if (!res.ok) {
throw new Error('Error');
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.stats[db.account.pubkey].pub_note_count)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Posts
</p>
<Link
to={`/users/${db.account.pubkey}`}
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
View
</Link>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,68 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { EditIcon, LoaderIcon } from '@shared/icons';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ProfileCard() {
const { db } = useStorage();
const { isLoading, user } = useProfile(db.account.pubkey);
const svgURI =
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(db.account.pubkey, 90, 50));
return (
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<div className="flex h-10 w-full justify-end">
<Link
to="/settings/edit-profile"
className="inline-flex h-8 w-20 items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-600"
>
<EditIcon className="h-4 w-4" />
Edit
</Link>
</div>
<div className="flex flex-col gap-2.5">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={db.account.pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-16 w-16 rounded-xl border border-neutral-200/50 shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] dark:border-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={db.account.pubkey}
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div>
<h3 className="text-3xl font-semibold leading-8 text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.name}
</h3>
<p className="text-lg text-neutral-700 dark:text-neutral-300">
{user?.nip05 || displayNpub(db.account.pubkey, 16)}
</p>
</div>
</div>
</div>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { EditIcon, LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
export function RelayCard() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['relays'],
queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const relays = await user.relayList();
if (!relays) return Promise.reject(new Error("user's relay set not found"));
return relays;
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.relays?.length || 0)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Relays
</p>
<Link
to="/relays"
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}
-28
View File
@@ -1,28 +0,0 @@
import { getVersion } from '@tauri-apps/plugin-app';
import { useEffect, useState } from 'react';
export function VersionSetting() {
const [version, setVersion] = useState<string>('');
useEffect(() => {
async function checkVersion() {
const appVersion = await getVersion();
setVersion(appVersion);
}
checkVersion();
}, []);
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-neutral-200">Version</span>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
You&apos;re using latest version
</span>
</div>
<div className="inline-flex items-center gap-2">
<span className="font-medium text-neutral-300">{version}</span>
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
export function ZapCard() {
const { db } = useStorage();
const { status, data } = useQuery({
queryKey: ['user-stats', db.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${db.account.pubkey}`,
{
signal,
}
);
if (!res.ok) {
throw new Error('Error');
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data?.stats[db.account.pubkey]?.zaps_received?.msats / 1000 || 0
)}
</h3>
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Sats received
</div>
</div>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function EditContactScreen() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['contacts'],
queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const follows = await user.follows();
return [...follows];
},
refetchOnWindowFocus: false,
});
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
{status === 'pending' ? (
<div className="flex h-10 w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data.map((item) => (
<div
key={item.pubkey}
className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900"
>
<User pubkey={item.pubkey} variant="simple" />
</div>
))
)}
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { NDKEvent, NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk';
import { useQueryClient } from '@tanstack/react-query';
import { message } from '@tauri-apps/plugin-dialog';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function EditProfileScreen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState('');
const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: true, text: '' });
const { db } = useStorage();
const { ndk } = useNDK();
const { upload } = useNostr();
const {
register,
handleSubmit,
reset,
setError,
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res: NDKUserProfile = queryClient.getQueryData(['user', db.account.pubkey]);
if (res.image) {
setPicture(res.image);
}
if (res.banner) {
setBanner(res.banner);
}
if (res.nip05) {
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
}
return res;
},
});
const uploadAvatar = async () => {
try {
if (!ndk.signer) return navigate('/new/privkey');
setLoading(true);
const image = await upload();
if (image) {
setPicture(image);
setLoading(false);
}
} catch (e) {
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const uploadBanner = async () => {
try {
setLoading(true);
const image = await upload();
if (image) {
setBanner(image);
setLoading(false);
}
} catch (e) {
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const onSubmit = async (data: NDKUserProfile) => {
// start loading
setLoading(true);
const content = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
image: data.picture,
};
const event = new NDKEvent(ndk);
event.kind = NDKKind.Metadata;
event.tags = [];
if (data.nip05) {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const verify = await user.validateNip05(data.nip05);
if (verify) {
event.content = JSON.stringify({ ...content, nip05: data.nip05 });
} else {
setNIP05((prev) => ({ ...prev, verified: false }));
setError('nip05', {
type: 'manual',
message: "Can't verify your Lume ID / NIP-05, please check again",
});
}
} else {
event.content = JSON.stringify(content);
}
const publishedRelays = await event.publish();
if (publishedRelays) {
// invalid cache
queryClient.invalidateQueries({
queryKey: ['user', db.account.pubkey],
});
// reset form
reset();
// reset state
setLoading(false);
setPicture(null);
setBanner(null);
} else {
setLoading(false);
}
};
return (
<div className="mx-auto w-full max-w-md">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<div className="flex flex-col items-center justify-center">
<div className="relative h-36 w-full">
{banner ? (
<img
src={banner}
alt="user's banner"
className="h-full w-full rounded-xl object-cover"
/>
) : (
<div className="h-full w-full rounded-xl bg-neutral-200 dark:bg-neutral-900" />
)}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform overflow-hidden rounded-xl">
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/20 text-white"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl object-cover"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50 text-white"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label
htmlFor="display_name"
className="text-sm font-semibold uppercase tracking-wider"
>
Display Name
</label>
<input
type={'text'}
{...register('display_name')}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider"
>
Name
</label>
<input
type={'text'}
{...register('name')}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="nip05"
className="text-sm font-semibold uppercase tracking-wider"
>
NIP-05
</label>
<div className="relative">
<input
{...register('nip05')}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
{nip05.verified ? (
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
<CheckCircleIcon className="h-4 w-4" />
Verified
</span>
) : (
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
<UnverifiedIcon className="h-4 w-4" />
Unverified
</span>
)}
</div>
{errors.nip05 && (
<p className="mt-1 text-sm text-red-400">
{errors.nip05.message.toString()}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
Website
</label>
<input
type={'text'}
{...register('website', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
Lightning address
</label>
<input
type={'text'}
{...register('lud16', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider"
>
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className="mx-auto inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
) : (
'Update'
)}
</button>
</div>
</div>
</form>
</div>
);
}
+263 -12
View File
@@ -1,17 +1,268 @@
import { AutoStartSetting } from '@app/settings/components/autoStart'; import * as Switch from '@radix-ui/react-switch';
import { DataPath } from '@app/settings/components/dataPath'; import { invoke } from '@tauri-apps/api/primitives';
import { VersionSetting } from '@app/settings/components/version'; import { getCurrent } from '@tauri-apps/api/window';
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
import { DarkIcon, LightIcon, SystemModeIcon } from '@shared/icons';
export function GeneralSettingScreen() {
const { db } = useStorage();
const [settings, setSettings] = useState({
autoupdate: false,
autolaunch: false,
outbox: false,
media: true,
hashtag: true,
notification: true,
appearance: 'system',
});
const changeTheme = async (theme: 'light' | 'dark' | 'auto') => {
await invoke('plugin:theme|set_theme', { theme });
// update state
setSettings((prev) => ({ ...prev, appearance: theme }));
};
const toggleAutolaunch = async () => {
if (!settings.autolaunch) {
await enable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: true }));
} else {
await disable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: false }));
}
};
const toggleOutbox = async () => {
await db.createSetting('outbox', String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleMedia = async () => {
await db.createSetting('media', String(+!settings.media));
db.settings.media = !settings.media;
// update state
setSettings((prev) => ({ ...prev, media: !settings.media }));
};
const toggleHashtag = async () => {
await db.createSetting('hashtag', String(+!settings.hashtag));
db.settings.hashtag = !settings.hashtag;
// update state
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
};
const toggleAutoupdate = async () => {
await db.createSetting('autoupdate', String(+!settings.autoupdate));
db.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
if (settings.notification) return;
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const theme = await getCurrent().theme();
setSettings((prev) => ({ ...prev, appearance: theme }));
const autostart = await isEnabled();
setSettings((prev) => ({ ...prev, autolaunch: autostart }));
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await db.getAllSettings();
if (!data) return;
data.forEach((item) => {
if (item.key === 'autoupdate')
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === 'outbox')
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
if (item.key === 'media')
setSettings((prev) => ({
...prev,
media: !!parseInt(item.value),
}));
if (item.key === 'hashtag')
setSettings((prev) => ({
...prev,
hashtag: !!parseInt(item.value),
}));
});
}
loadSettings();
}, []);
export function GeneralSettingsScreen() {
return ( return (
<div className="h-full w-full px-3 pt-11"> <div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-6">
<h1 className="text-xl font-semibold text-white">General</h1> <div className="flex w-full items-center justify-between">
<div className="w-full rounded-xl bg-neutral-400 dark:bg-neutral-600"> <div className="flex items-center gap-8">
<div className="flex h-full w-full flex-col divide-y divide-white/5"> <div className="w-24 shrink-0 text-end text-sm font-semibold">Updater</div>
<AutoStartSetting /> <div className="text-sm">Auto download new update at Login</div>
<DataPath /> </div>
<VersionSetting /> <Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Startup</div>
<div className="text-sm">Launch Lume at Login</div>
</div>
<Switch.Root
checked={settings.autolaunch}
onClick={() => toggleAutolaunch()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Gossip</div>
<div className="text-sm">Use Outbox model</div>
</div>
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Media</div>
<div className="text-sm">Automatically load media</div>
</div>
<Switch.Root
checked={settings.media}
onClick={() => toggleMedia()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Hashtag</div>
<div className="text-sm">Hide all hashtags in content</div>
</div>
<Switch.Root
checked={settings.hashtag}
onClick={() => toggleHashtag()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Notification
</div>
<div className="text-sm">Automatically send notification</div>
</div>
<Switch.Root
checked={settings.notification}
disabled={settings.notification}
onClick={() => toggleNofitication()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Appearance</div>
<div className="flex flex-1 gap-6">
<button
type="button"
onClick={() => changeTheme('light')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'light'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<LightIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Light
</p>
</button>
<button
type="button"
onClick={() => changeTheme('dark')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'dark'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<DarkIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Dark
</p>
</button>
<button
type="button"
onClick={() => changeTheme('auto')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'auto'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<SystemModeIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
System
</p>
</button>
</div> </div>
</div> </div>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
import { ContactCard } from '@app/settings/components/contactCard';
import { PostCard } from '@app/settings/components/postCard';
import { ProfileCard } from '@app/settings/components/profileCard';
import { RelayCard } from '@app/settings/components/relayCard';
import { ZapCard } from '@app/settings/components/zapCard';
export function UserSettingScreen() {
return (
<div className="mx-auto w-full max-w-xl">
<ProfileCard />
<div className="grid grid-cols-2 gap-4">
<ContactCard />
<RelayCard />
<PostCard />
<ZapCard />
</div>
</div>
);
}
-118
View File
@@ -1,118 +0,0 @@
import { CommandIcon } from '@shared/icons';
export function ShortcutsSettingsScreen() {
return (
<div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-white">Shortcuts</h1>
<div className="w-full rounded-xl bg-neutral-400 dark:bg-neutral-600">
<div className="flex h-full w-full flex-col divide-y divide-white/5">
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-white">Open composer</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<CommandIcon
width={12}
height={12}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
N
</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-white">
Add image block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<CommandIcon
width={12}
height={12}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
I
</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-white">
Add newsfeed block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<CommandIcon
width={12}
height={12}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
F
</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-white">
Open personal page
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<CommandIcon
width={12}
height={12}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
P
</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-white">
Open notification
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<CommandIcon
width={12}
height={12}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
B
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { useStorage } from '@libs/storage/provider';
import { HandArrowDownIcon, PlusIcon } from '@shared/icons';
import { WidgetKinds, useWidgets } from '@stores/widgets';
export function ToggleWidgetList() {
const { db } = useStorage();
const setWidget = useWidgets((state) => state.setWidget);
return (
<div className="flex h-full w-[420px] items-center justify-center border-r border-neutral-100 dark:border-neutral-900">
<div className="relative">
<div className="absolute -top-44 left-1/2 -translate-x-1/2 transform">
<HandArrowDownIcon className="text-neutral-100 dark:text-neutral-900" />
</div>
<button
type="button"
onClick={() =>
setWidget(db, { kind: WidgetKinds.tmp.list, title: '', content: '' })
}
className="inline-flex h-9 items-center gap-2 rounded-full bg-neutral-200 px-3 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<PlusIcon className="h-4 w-4 text-neutral-900 dark:text-zinc-100" />
<p className="text-sm font-semibold leading-none">Add widget</p>
</button>
</div>
</div>
);
}
-144
View File
@@ -1,144 +0,0 @@
import { useCallback } from 'react';
import { useStorage } from '@libs/storage/provider';
import {
ArticleIcon,
BellIcon,
FileIcon,
FollowsIcon,
GroupFeedsIcon,
HashtagIcon,
ThreadsIcon,
TrendingIcon,
} from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { DefaultWidgets, WidgetKinds, useWidgets } from '@stores/widgets';
import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types';
export function WidgetList({ params }: { params: Widget }) {
const { db } = useStorage();
const [setWidget, removeWidget] = useWidgets((state) => [
state.setWidget,
state.removeWidget,
]);
const openWidget = (widget: WidgetGroupItem) => {
setWidget(db, { kind: widget.kind, title: widget.title, content: '' });
removeWidget(db, params.id);
};
const renderIcon = useCallback(
(kind: number) => {
switch (kind) {
case WidgetKinds.tmp.xfeed:
return (
<GroupFeedsIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
);
case WidgetKinds.local.follows:
return (
<FollowsIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
);
case WidgetKinds.local.files:
case WidgetKinds.global.files:
return <FileIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />;
case WidgetKinds.local.articles:
case WidgetKinds.global.articles:
return (
<ArticleIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
);
case WidgetKinds.tmp.xhashtag:
return (
<HashtagIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
);
case WidgetKinds.nostrBand.trendingAccounts:
case WidgetKinds.nostrBand.trendingNotes:
return (
<TrendingIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
);
case WidgetKinds.local.notification:
return <BellIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />;
case WidgetKinds.other.learnNostr:
return (
<ThreadsIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
);
default:
return null;
}
},
[DefaultWidgets]
);
const renderItem = useCallback(
(row: WidgetGroup, index: number) => {
return (
<div key={index} className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-300">
{row.title}
</h3>
<div className="flex flex-col divide-y divide-neutral-100 overflow-hidden rounded-xl bg-neutral-50 dark:divide-neutral-900 dark:bg-neutral-950">
{row.data.map((item, index) => (
<button
onClick={() => openWidget(item)}
key={index}
className="group flex items-center gap-2.5 px-4 hover:bg-neutral-200 dark:hover:bg-neutral-800"
>
{item.icon ? (
<div className="h-10 w-10 shrink-0 rounded-md">
<img
src={item.icon}
alt={item.title}
className="h-10 w-10 object-cover"
/>
</div>
) : (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-neutral-200 group-hover:bg-neutral-300 dark:bg-neutral-800 dark:group-hover:bg-neutral-700">
{renderIcon(item.kind)}
</div>
)}
<div className="inline-flex h-16 w-full flex-col items-start justify-center">
<h5 className="line-clamp-1 text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{item.title}
</h5>
<p className="line-clamp-1 text-sm text-neutral-500 dark:text-neutral-300">
{item.description}
</p>
</div>
</button>
))}
</div>
</div>
);
},
[DefaultWidgets]
);
return (
<div className="h-full w-[420px] border-r border-neutral-100 dark:border-neutral-900">
<TitleBar id={params.id} title="Add widget" />
<div className="h-full overflow-y-auto pb-20 scrollbar-none">
<div className="flex flex-col gap-6 px-3">
{DefaultWidgets.map((row: WidgetGroup, index: number) =>
renderItem(row, index)
)}
<div className="border-t border-neutral-200 pt-6 dark:border-neutral-800">
<button
type="button"
disabled
className="inline-flex h-14 w-full items-center justify-center gap-2.5 rounded-xl bg-neutral-100 text-sm font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100"
>
Build your own widget{' '}
<div className="-rotate-3 transform-gpu rounded-md border border-neutral-300 bg-neutral-200 px-1.5 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<span className="bg-gradient-to-r from-blue-400 via-red-400 to-orange-500 bg-clip-text text-xs text-transparent dark:from-blue-200 dark:via-red-200 dark:to-orange-300">
Coming soon
</span>
</div>
</button>
</div>
</div>
</div>
</div>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { VList, VListHandle } from 'virtua';
import { ToggleWidgetList } from '@app/space/components/toggle';
import { WidgetList } from '@app/space/components/widgetList';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import {
GlobalArticlesWidget,
GlobalFilesWidget,
GlobalHashtagWidget,
LearnNostrWidget,
LocalArticlesWidget,
LocalFeedsWidget,
LocalFilesWidget,
LocalFollowsWidget,
LocalNetworkWidget,
LocalNotificationWidget,
LocalThreadWidget,
LocalUserWidget,
TrendingAccountsWidget,
TrendingNotesWidget,
XfeedsWidget,
XhashtagWidget,
} from '@shared/widgets';
import { WidgetKinds, useWidgets } from '@stores/widgets';
import { Widget } from '@utils/types';
export function SpaceScreen() {
const { db } = useStorage();
const vlistRef = useRef<VListHandle>(null);
const [widgets, fetchWidgets] = useWidgets((state) => [
state.widgets,
state.fetchWidgets,
]);
const renderItem = useCallback(
(widget: Widget) => {
if (!widget) return;
switch (widget.kind) {
case WidgetKinds.local.network:
return <LocalNetworkWidget key={widget.id} />;
case WidgetKinds.local.follows:
return <LocalFollowsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.feeds:
return <LocalFeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.files:
return <LocalFilesWidget key={widget.id} params={widget} />;
case WidgetKinds.local.articles:
return <LocalArticlesWidget key={widget.id} params={widget} />;
case WidgetKinds.local.user:
return <LocalUserWidget key={widget.id} params={widget} />;
case WidgetKinds.local.thread:
return <LocalThreadWidget key={widget.id} params={widget} />;
case WidgetKinds.global.hashtag:
return <GlobalHashtagWidget key={widget.id} params={widget} />;
case WidgetKinds.global.articles:
return <GlobalArticlesWidget key={widget.id} params={widget} />;
case WidgetKinds.global.files:
return <GlobalFilesWidget key={widget.id} params={widget} />;
case WidgetKinds.nostrBand.trendingAccounts:
return <TrendingAccountsWidget key={widget.id} params={widget} />;
case WidgetKinds.nostrBand.trendingNotes:
return <TrendingNotesWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.xfeed:
return <XfeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.xhashtag:
return <XhashtagWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.list:
return <WidgetList key={widget.id} params={widget} />;
case WidgetKinds.other.learnNostr:
return <LearnNostrWidget key={widget.id} params={widget} />;
case WidgetKinds.local.notification:
return <LocalNotificationWidget key={widget.id} params={widget} />;
default:
return null;
}
},
[widgets]
);
useEffect(() => {
fetchWidgets(db);
}, []);
return (
<VList
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
horizontal
ref={vlistRef}
initialItemSize={420}
aria-current="step"
tabIndex={0}
>
{!widgets ? (
<div className="flex h-full w-[420px] flex-col items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
) : (
widgets.map((widget) => renderItem(widget))
)}
<ToggleWidgetList />
</VList>
);
}
-416
View File
@@ -1,416 +0,0 @@
import { NDKEvent, NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { fetch } from '@tauri-apps/plugin-http';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import {
CancelIcon,
CheckCircleIcon,
LoaderIcon,
PlusIcon,
UnverifiedIcon,
} from '@shared/icons';
interface NIP05 {
names: {
[key: string]: string;
};
}
export function EditProfileModal() {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState('');
const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: false, text: '' });
const { db } = useStorage();
const { ndk } = useNDK();
const {
register,
handleSubmit,
reset,
setError,
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res: NDKUserProfile = queryClient.getQueryData(['user', db.account.pubkey]);
if (res.image) {
setPicture(res.image);
}
if (res.banner) {
setBanner(res.banner);
}
if (res.nip05) {
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
}
return res;
},
});
const verifyNIP05 = async (nip05: string) => {
const localPath = nip05.split('@')[0];
const service = nip05.split('@')[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: 'GET',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data: NIP05 = await res.json();
if (data.names) {
if (data.names[localPath] !== db.account.pubkey) return false;
return true;
}
return false;
};
const uploadAvatar = async () => {
try {
// start loading
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append('fileToUpload', blob);
data.append('submit', 'Upload Image');
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setPicture(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const uploadBanner = async () => {
try {
// start loading
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append('fileToUpload', blob);
data.append('submit', 'Upload Image');
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setBanner(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const onSubmit = async (data: NDKUserProfile) => {
// start loading
setLoading(true);
const content = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
image: data.picture,
};
const event = new NDKEvent(ndk);
event.kind = NDKKind.Metadata;
event.tags = [];
if (data.nip05) {
const nip05IsVerified = await verifyNIP05(data.nip05);
if (nip05IsVerified) {
event.content = JSON.stringify({ ...content, nip05: data.nip05 });
} else {
setNIP05((prev) => ({ ...prev, verified: false }));
setError('nip05', {
type: 'manual',
message: "Can't verify your Lume ID / NIP-05, please check again",
});
}
} else {
event.content = JSON.stringify(content);
}
const publishedRelays = await event.publish();
if (publishedRelays) {
// invalid cache
queryClient.invalidateQueries(['user', db.account.pubkey]);
// reset form
reset();
// reset state
setLoading(false);
setIsOpen(false);
setPicture('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
setBanner(null);
} else {
setLoading(false);
}
};
useEffect(() => {
if (!nip05.verified && /\S+@\S+\.\S+/.test(nip05.text)) {
verifyNIP05(nip05.text);
}
}, [nip05.text]);
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Edit profile
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-neutral-100 dark:bg-neutral-900">
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-neutral-200 px-5 py-5 dark:border-neutral-800">
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
Edit profile
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800">
<CancelIcon className="h-4 w-4" />
</Dialog.Close>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<div className="relative">
<div className="relative h-44 w-full">
{banner ? (
<img
src={banner}
alt="user's banner"
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-black dark:bg-white" />
)}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/50"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-neutral-900">
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl object-cover"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
>
Name
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="nip05"
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
>
NIP-05
</label>
<div className="relative">
<input
{...register('nip05', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
{nip05.verified ? (
<span className="inline-flex h-6 items-center gap-1 rounded bg-green-500 px-2 text-sm font-medium text-white">
<CheckCircleIcon className="h-4 w-4 text-black dark:text-white" />
Verified
</span>
) : (
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 px-2 text-sm font-medium text-white">
<UnverifiedIcon className="h-4 w-4 text-black dark:text-white" />
Unverified
</span>
)}
</div>
{errors.nip05 && (
<p className="mt-1 text-sm text-red-400">
{errors.nip05.message.toString()}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
>
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-neutral-200 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
>
Website
</label>
<input
type={'text'}
{...register('website', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
>
Lightning address
</label>
<input
type={'text'}
{...register('lud16', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
) : (
'Update'
)}
</button>
</div>
</div>
</form>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
+66 -52
View File
@@ -1,15 +1,15 @@
import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { EditProfileModal } from '@app/users/components/modal';
import { UserStats } from '@app/users/components/stats'; import { UserStats } from '@app/users/components/stats';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05'; import { NIP05 } from '@shared/nip05';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
@@ -22,49 +22,57 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const follow = async (pubkey: string) => { const navigate = useNavigate();
try { const svgURI =
const user = ndk.getUser({ hexpubkey: db.account.pubkey }); 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50));
const contacts = await user.follows();
const add = await user.follow(new NDKUser({ hexpubkey: pubkey }), contacts);
if (add) { const follow = async () => {
setFollowed(true); try {
} else { if (!ndk.signer) return navigate('/new/privkey');
toast('You already follow this user'); setFollowed(true);
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows();
const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
if (!add) {
toast.success('You already follow this user');
setFollowed(false);
} }
} catch (error) { } catch (e) {
console.log(error); toast.error(e);
setFollowed(false);
} }
}; };
const unfollow = async (pubkey: string) => { const unfollow = async () => {
try { try {
const user = ndk.getUser({ hexpubkey: db.account.pubkey }); if (!ndk.signer) return navigate('/new/privkey');
setFollowed(false);
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows(); const contacts = await user.follows();
contacts.delete(new NDKUser({ hexpubkey: pubkey })); contacts.delete(new NDKUser({ pubkey: pubkey }));
let list: string[][];
contacts.forEach((el) =>
list.push(['p', el.pubkey, el.relayUrls?.[0] || '', el.profile?.name || ''])
);
const list = [...contacts].map((item) => [
'p',
item.pubkey,
item.relayUrls?.[0] || '',
'',
]);
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = ''; event.content = '';
event.kind = NDKKind.Contacts; event.kind = NDKKind.Contacts;
event.tags = list; event.tags = list;
const publishedRelays = await event.publish(); await event.publish();
if (publishedRelays) { } catch (e) {
setFollowed(false); toast.error(e);
}
} catch (error) {
console.log(error);
} }
}; };
useEffect(() => { useEffect(() => {
if (db.account.follows.includes(pubkey)) { if (db.account.contacts.includes(pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);
@@ -74,9 +82,9 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
return ( return (
<> <>
<div className="h-56 w-full overflow-hidden rounded-tl-lg"> <div className="h-56 w-full overflow-hidden rounded-tl-lg">
{user.banner ? ( {user?.banner ? (
<img <img
src={user.banner} src={user?.banner}
alt="user banner" alt="user banner"
className="h-full w-full rounded-tl-lg object-cover" className="h-full w-full rounded-tl-lg object-cover"
/> />
@@ -85,22 +93,34 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
)} )}
</div> </div>
<div className="-mt-7 flex w-full flex-col items-center px-5"> <div className="-mt-7 flex w-full flex-col items-center px-5">
<Image <Avatar.Root className="shrink-0">
src={user.picture || user.image} <Avatar.Image
alt={pubkey} src={user?.picture || user?.image}
className="h-14 w-14 rounded-lg ring-2 ring-neutral-100 dark:ring-neutral-900" alt={pubkey}
/> loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-14 w-14 rounded-lg bg-white object-cover ring-2 ring-neutral-100 dark:ring-neutral-900"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-14 w-14 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="mt-2 flex flex-1 flex-col gap-6"> <div className="mt-2 flex flex-1 flex-col gap-6">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="inline-flex flex-col items-center"> <div className="inline-flex flex-col items-center">
<h5 className="text-center text-xl font-semibold text-neutral-900 dark:text-neutral-100"> <h5 className="text-center text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{user.name || user.display_name || user.displayName || 'No name'} {user?.name || user?.display_name || user?.displayName || 'No name'}
</h5> </h5>
{user.nip05 ? ( {user?.nip05 ? (
<NIP05 <NIP05
pubkey={pubkey} pubkey={pubkey}
nip05={user?.nip05} nip05={user.nip05}
className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400" className="text-neutral-600 dark:text-neutral-400"
/> />
) : ( ) : (
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400"> <span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400">
@@ -109,7 +129,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
)} )}
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{user.about || user.bio ? ( {user?.about || user?.bio ? (
<p className="mt-2 max-w-[500px] select-text break-words text-center text-neutral-900 dark:text-neutral-100"> <p className="mt-2 max-w-[500px] select-text break-words text-center text-neutral-900 dark:text-neutral-100">
{user.about || user.bio} {user.about || user.bio}
</p> </p>
@@ -123,32 +143,26 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
{followed ? ( {followed ? (
<button <button
type="button" type="button"
onClick={() => unfollow(pubkey)} onClick={unfollow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
> >
Unfollow Unfollow
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => follow(pubkey)} onClick={follow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
> >
Follow Follow
</button> </button>
)} )}
<Link <Link
to={`/chats/${pubkey}`} to={`/chats/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
> >
Message Message
</Link> </Link>
{db.account.pubkey === pubkey && (
<>
<span className="mx-2 inline-flex h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
<EditProfileModal />
</>
)}
</div> </div>
</div> </div>
</div> </div>
+23 -15
View File
@@ -1,19 +1,31 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number'; import { compactNumber } from '@utils/number';
export function UserStats({ pubkey }: { pubkey: string }) { export function UserStats({ pubkey }: { pubkey: string }) {
const { status, data } = useQuery(['user-metadata', pubkey], async () => { const { status, data } = useQuery({
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`); queryKey: ['user-stats', pubkey],
if (!res.ok) { queryFn: async ({ signal }: { signal: AbortSignal }) => {
throw new Error('Error'); const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`, {
} signal,
return await res.json(); });
if (!res.ok) {
throw new Error('Error');
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
}); });
if (status === 'loading') { if (status === 'pending') {
return ( return (
<div className="flex w-full items-center justify-center"> <div className="flex w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" /> <LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
@@ -29,7 +41,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
<div className="flex w-full items-center justify-center gap-10"> <div className="flex w-full items-center justify-center gap-10">
<div className="inline-flex flex-col items-center gap-1"> <div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100"> <span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.stats[pubkey].followers_pubkey_count) ?? 0} {compactNumber.format(data?.stats[pubkey]?.followers_pubkey_count) ?? 0}
</span> </span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400"> <span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Followers Followers
@@ -37,7 +49,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
</div> </div>
<div className="inline-flex flex-col items-center gap-1"> <div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100"> <span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.stats[pubkey].pub_following_pubkey_count) ?? 0} {compactNumber.format(data?.stats[pubkey]?.pub_following_pubkey_count) ?? 0}
</span> </span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400"> <span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Following Following
@@ -45,9 +57,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
</div> </div>
<div className="inline-flex flex-col items-center gap-1"> <div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100"> <span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{data.stats[pubkey].zaps_received {compactNumber.format(data?.stats[pubkey]?.zaps_received?.msats / 1000 ?? 0)}
? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
: 0}
</span> </span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400"> <span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Zaps received Zaps received
@@ -55,9 +65,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
</div> </div>
<div className="inline-flex flex-col items-center gap-1"> <div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100"> <span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{data.stats[pubkey].zaps_sent {compactNumber.format(data?.stats[pubkey]?.zaps_sent?.msats / 1000 ?? 0)}
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
: 0}
</span> </span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400"> <span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Zaps sent Zaps sent

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